packages feed

tasklite-core-0.5.0.0: source/Lib.hs

{-# LANGUAGE QuasiQuotes #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}

{-# HLINT ignore "Use maybe" #-}

{-|
Functions to create, update, and delete tasks / tags / notes
-}
module Lib where

import Protolude (
  Applicative (liftA2, pure),
  Bool (..),
  Char,
  Double,
  Down (Down),
  Either (Left, Right),
  Eq (..),
  FilePath,
  Float,
  Floating (logBase),
  Fractional ((/)),
  Functor (fmap),
  IO,
  Int,
  Int64,
  Integer,
  Integral (toInteger),
  Maybe (..),
  Monad (return, (>>=)),
  MonadIO (liftIO),
  Monoid (mempty),
  Num (abs, fromInteger, (*), (+), (-)),
  Ord (compare, max, (<), (>)),
  Read,
  RealFrac (ceiling, floor),
  Semigroup ((<>)),
  Show,
  Text,
  catMaybes,
  const,
  decodeUtf8,
  either,
  encodeUtf8,
  exitFailure,
  forM,
  forM_,
  fromIntegral,
  fromMaybe,
  fst,
  getArgs,
  identity,
  isJust,
  isNothing,
  isSpace,
  listToMaybe,
  maybe,
  maybeToEither,
  not,
  on,
  otherwise,
  realToFrac,
  show,
  snd,
  sortBy,
  sortOn,
  stderr,
  unlines,
  unwords,
  ($),
  ($>),
  (&),
  (&&),
  (.),
  (<$>),
  (<&>),
  (<=),
  (||),
 )
import Protolude qualified as P

import Control.Applicative ((<|>))
import Control.Arrow ((>>>))
import Data.Aeson as Aeson (KeyValue ((.=)), encode, object)
import Data.Coerce (coerce)
import Data.Generics (Data, constrFields, toConstr)
import Data.Hourglass (
  DateTime (dtTime),
  Duration (durationHours, durationMinutes),
  ISO8601_Date (ISO8601_Date),
  Minutes (Minutes),
  Seconds (Seconds),
  Time (timeFromElapsedP),
  TimeOfDay (todNSec),
  timeAdd,
  timeDiff,
  timePrint,
 )
import Data.List (nub)
import Data.Text qualified as T
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Encoding qualified as TL
import Data.Time.Clock (UTCTime)
import Data.Time.ISO8601.Duration qualified as Iso
import Data.ULID (ULID, getULID)
import Data.Yaml qualified as Yaml
import Database.SQLite.Simple (
  Connection,
  Error (ErrorConstraint),
  FromRow (..),
  NamedParam ((:=)),
  Only (Only),
  Query (Query),
  SQLData (SQLNull, SQLText),
  SQLError (sqlError),
  ToRow,
  changes,
  execute,
  executeNamed,
  field,
  open,
  query,
  queryNamed,
  query_,
  toRow,
  withConnection,
 )
import Database.SQLite.Simple.QQ (sql)
import Numeric (showFFloat)
import Prettyprinter as Pp (
  Doc,
  Pretty (pretty),
  align,
  annotate,
  concatWith,
  dquotes,
  fill,
  hang,
  hardline,
  hsep,
  indent,
  line,
  punctuate,
  vcat,
  vsep,
  (<+>),
 )
import Prettyprinter.Render.Terminal (
  AnsiStyle,
  Color (Black, Green, Red, Yellow),
  bold,
  color,
  hPutDoc,
  underlined,
 )
import Prettyprinter.Util (reflow)
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))
import System.Posix.User (getEffectiveUserName)
import System.Process (readProcess)
import Text.Fuzzily qualified as Fuzzily
import Text.ParserCombinators.ReadP (
  ReadP,
  char,
  eof,
  munch,
  munch1,
  readP_to_S,
  sepBy1,
  skipSpaces,
  string,
  (<++),
 )
import Text.Printf (printf)
import Time.System (dateCurrent, timeCurrentP)

import Config (
  Column (..),
  Config (
    bodyClosedStyle,
    bodyStyle,
    bodyWidth,
    closedStyle,
    columns,
    dataDir,
    dateStyle,
    dateWidth,
    dbName,
    dueStyle,
    headCount,
    hooks,
    idStyle,
    prioWidth,
    priorityStyle,
    progressBarWidth,
    tableName,
    tagStyle,
    utcFormat,
    utcFormatShort
  ),
  HookSet (post, pre),
  HooksConfig (add),
  defaultConfig,
 )
import Control.Monad.Catch (catchAll, catchIf)
import FullTask (
  FullTask (
    awake_utc,
    body,
    closed_utc,
    due_utc,
    group_ulid,
    metadata,
    modified_utc,
    notes,
    priority,
    ready_utc,
    recurrence_duration,
    repetition_duration,
    review_utc,
    tags,
    ulid,
    user,
    waiting_utc
  ),
  cpTimesAndState,
  selectQuery,
 )
import Hooks (HookResult (message, task), executeHooks, formatHookResult)
import ImportTask (
  ImportTask (ImportTask, notes, tags, task),
  importTaskToFullTask,
  setMissingFields,
 )
import Note (Note (body, ulid))
import Prettyprinter.Internal.Type (Doc (Empty))
import SqlUtils (quoteKeyword, quoteText)
import Task (
  DerivedState (IsOpen),
  Task,
  TaskState (..),
  derivedStateToQuery,
  emptyTask,
  getStateHierarchy,
  textToDerivedState,
 )
import Task qualified
import TaskToNote (TaskToNote (..))
import TaskToTag (TaskToTag (..))
import Utils (
  IdText,
  ListModifiedFlag (..),
  applyColorMode,
  bgColrDull,
  colr,
  colrDull,
  countChar,
  dateTimeToUtcTime,
  formatElapsedP,
  numDigits,
  parseUlidText,
  parseUtc,
  setDateTime,
  ulidTextToDateTime,
  utcFormatReadable,
  utcTimeToDateTime,
  vsepCollapse,
  (<$$>),
  (<++>),
 )


noTasksWarning :: Text
noTasksWarning =
  "No tasks available. "
    <> "Run `tasklite help` to learn how to create tasks."


newtype NumRows = NumRows Integer
  deriving (Eq, Ord, Read, Show)


instance FromRow NumRows where
  fromRow = NumRows <$> field


getDbPath :: Config -> IO FilePath
getDbPath conf = do
  pure $ conf.dataDir </> conf.dbName


setupConnection :: Config -> IO Connection
setupConnection conf = do
  createDirectoryIfMissing True $ dataDir conf
  open $ conf.dataDir </> conf.dbName


execWithConn :: Config -> (Connection -> IO a) -> IO a
execWithConn conf func = do
  createDirectoryIfMissing True $ dataDir conf
  withConnection
    (conf.dataDir </> conf.dbName)
    func


-- | Get fields names of record (empty list if not record constructor)
getRecordFields :: (Data object) => object -> [Text]
getRecordFields =
  toConstr >>> constrFields >>> fmap T.pack


insertRecord :: (ToRow r, Data r) => Text -> Connection -> r -> IO ()
insertRecord tableName connection record = do
  let recordFields = record & getRecordFields
  execute
    connection
    ( Query $
        "INSERT INTO "
          <> tableName
          <> "("
          <> (recordFields & T.intercalate ",\n")
          <> ") VALUES ("
          <> ((recordFields $> "?") & T.intercalate ",\n")
          <> ")"
    )
    (toRow record)


getUpdateAssignments :: Task -> Text
getUpdateAssignments task =
  task
    & getRecordFields
    <&> (<> " = ?")
    & T.intercalate ", "


updateTask :: Connection -> Task -> IO ()
updateTask connection task = do
  execute
    connection
    ( Query $
        " UPDATE tasks SET "
          <> getUpdateAssignments task
          <> " WHERE ulid == ?"
    )
    (toRow task <> [SQLText task.ulid])


handleTagDupError :: Config -> Text -> (Applicative f) => e -> f (Doc AnsiStyle)
handleTagDupError conf tag _exception =
  pure $
    annotate (colr conf Yellow) $
      "⚠️ Tag " <> dquotes (pretty tag) <> " is already assigned"


insertTags ::
  Config -> Connection -> Maybe DateTime -> Task -> [Text] -> IO (Doc AnsiStyle)
insertTags conf connection mbCreatedUtc task tags = do
  let uniqueTags = nub tags
  taskToTags <- forM uniqueTags $ \tag -> do
    newUlid <- getULID
    pure $
      TaskToTag
        { ulid =
            mbCreatedUtc
              <&> setDateTime newUlid
              & fromMaybe newUlid
              & show
              & T.toLower
        , task_ulid = task.ulid
        , tag = tag
        }

  -- TODO: Insert all tags at once
  insertWarnings <- P.forM taskToTags $ \taskToTag ->
    catchIf
      -- TODO: Find out why it's not `ErrorConstraintUnique`
      (\(err :: SQLError) -> err.sqlError == ErrorConstraint)
      (insertRecord "task_to_tag" connection taskToTag P.>> pure "")
      (handleTagDupError conf taskToTag.tag)

  pure $ vsepCollapse insertWarnings


insertNotes ::
  Config ->
  Connection ->
  Maybe DateTime ->
  Task ->
  [Note] ->
  IO (Doc AnsiStyle)
insertNotes conf connection mbCreatedUtc task notes = do
  let uniqueNotes = nub notes
  taskToNotes <- forM uniqueNotes $ \theNote -> do
    newUlid <- getULID
    pure $
      TaskToNote
        { ulid =
            theNote.ulid
              & parseUlidText
              & fromMaybe newUlid
              & case mbCreatedUtc of
                Nothing -> P.identity
                Just createdUtc -> P.flip setDateTime createdUtc
              & show @ULID
              & T.toLower
        , task_ulid = task.ulid
        , note = theNote.body
        }

  insertWarnings <- P.forM taskToNotes $ \taskToNote ->
    catchAll
      (insertRecord "task_to_note" connection taskToNote P.>> pure "")
      ( \exception ->
          pure $
            annotate (colr conf Yellow) $
              "⚠️ Note "
                <> dquotes (pretty taskToNote.note)
                <> " could not be inserted"
                <+> "ERROR:"
                <+> pretty (show exception :: Text)
      )

  pure $ vsepCollapse insertWarnings


-- | Tuple is (Maybe createdUtc, noteBody)
insertNoteTuples :: Connection -> Task -> [(Maybe DateTime, Text)] -> IO ()
insertNoteTuples connection task notes = do
  taskToNotes <- forM notes $ \(mbCreatedUtc, noteBody) -> do
    noteUlid <- getULID
    pure $
      TaskToNote
        { ulid =
            mbCreatedUtc
              <&> setDateTime noteUlid
              & fromMaybe noteUlid
              & show
              & T.toLower
        , task_ulid = task.ulid
        , note = noteBody
        }

  forM_ taskToNotes $ \taskToNote ->
    insertRecord "task_to_note" connection taskToNote


formatUlid :: IO ULID -> IO Text
formatUlid =
  fmap (T.toLower . show)


{-| Parses the body of the tasks and extracts all meta data
 | Returns a tuple (body, tags, dueUtcMb, createdUtcMb)
 TODO: Replace with parsec implementation
-}
parseTaskBody :: [Text] -> (Text, [Text], Maybe Text, Maybe DateTime)
parseTaskBody bodyWords =
  let
    isTag = ("+" `T.isPrefixOf`)
    isDueUtc = ("due:" `T.isPrefixOf`)
    isCreatedUtc = ("created:" `T.isPrefixOf`)
    isMeta word = isTag word || isDueUtc word || isCreatedUtc word
    -- Handle case when word is actually a text
    bodyWordsReversed = bodyWords & T.unwords & T.words & P.reverse
    body =
      bodyWordsReversed
        & P.dropWhile isMeta
        & P.reverse
        & unwords
    metadata =
      bodyWordsReversed
        & P.takeWhile isMeta
        & P.reverse
    tags = fmap (T.replace "+" "") (P.filter isTag metadata)
    dueUtcMb =
      metadata
        & P.filter isDueUtc
        <&> T.replace "due:" ""
        & P.lastMay
          >>= parseUtc
        <&> (timePrint utcFormatReadable >>> T.pack)
    createdUtcMb =
      metadata
        & P.filter isCreatedUtc
        <&> T.replace "created:" ""
        & P.lastMay
          >>= parseUtc
  in
    (body, tags, dueUtcMb, createdUtcMb)


-- | Get (ulid, modified_utc, effectiveUserName) from the environment
getTriple :: Config -> IO (ULID, Text, [Char])
getTriple conf = do
  ulid <- getULID
  -- TODO: Set via a SQL trigger
  modified_utc <- formatElapsedP conf timeCurrentP
  effectiveUserName <- getEffectiveUserName

  pure (ulid, modified_utc, effectiveUserName)


addEmptyTask :: Config -> Connection -> IO Task
addEmptyTask conf conn = do
  (ulid, modified_utc, effectiveUserName) <- getTriple conf
  let task =
        emptyTask
          { Task.ulid = T.toLower $ show ulid
          , Task.body = ""
          , Task.state = Just Done
          , Task.due_utc = Nothing
          , Task.closed_utc = Just modified_utc
          , Task.user = T.pack effectiveUserName
          , Task.modified_utc = modified_utc
          }

  insertRecord "tasks" conn task
  pure task


-- TODO: Eliminate code duplications with `editTask`
addTask :: Config -> Connection -> [Text] -> IO (Doc AnsiStyle)
addTask conf connection bodyWords = do
  (ulid, modified_utc, effectiveUserName) <- getTriple conf
  let
    (body, tags, dueUtcMb, createdUtcMb) = parseTaskBody bodyWords
    importTaskDraft =
      ImportTask
        { task =
            emptyTask
              { Task.ulid = T.toLower $ show $ case createdUtcMb of
                  Nothing -> ulid
                  Just createdUtc -> setDateTime ulid createdUtc
              , Task.body = body
              , Task.due_utc = dueUtcMb
              , Task.modified_utc = modified_utc
              , Task.user = T.pack effectiveUserName
              }
        , tags = tags
        , notes = [] -- TODO: Add notes to task
        }

  args <- getArgs
  preAddResults <-
    executeHooks
      ( TL.toStrict $
          TL.decodeUtf8 $
            Aeson.encode $
              object
                [ "arguments" .= args
                , "taskToAdd" .= importTaskToFullTask importTaskDraft
                ]
      )
      conf.hooks.add.pre

  -- Maybe the task was changed by the hook
  (importTask, preAddHookMsg) <- case preAddResults of
    [] -> pure (importTaskDraft, Empty)
    [Left error] -> do
      _ <- exitFailure
      pure (importTaskDraft, pretty error)
    [Right hookResult] -> do
      case hookResult.task of
        Nothing -> pure (importTaskDraft, Empty)
        Just task -> do
          fullImportTask <- setMissingFields task
          pure (fullImportTask, formatHookResult conf hookResult)
    _ -> do
      pure
        ( importTaskDraft
        , annotate (colr conf Red) $
            "ERROR: Multiple pre-add hooks are not supported yet. "
              <> "None of the hooks were executed."
        )

  insertRecord "tasks" connection importTask.task
  warnings <- insertTags conf connection Nothing importTask.task importTask.tags

  -- TODO: Use RETURNING clause in `insertRecord` instead
  (insertedTasks :: [FullTask]) <-
    queryNamed
      connection
      "SELECT * FROM tasks_view WHERE ulid == :ulid"
      [":ulid" := importTask.task.ulid]

  case insertedTasks of
    [insertedTask] -> do
      postAddResults <-
        executeHooks
          ( TL.toStrict $
              TL.decodeUtf8 $
                Aeson.encode $
                  object
                    [ "arguments" .= args
                    , "taskAdded" .= insertedTask
                    -- TODO: Add tags and notes to task
                    ]
          )
          conf.hooks.add.post

      let
        postAddHookMsg :: Doc AnsiStyle
        postAddHookMsg =
          ( postAddResults
              <&> \case
                Left error -> "ERROR:" <+> pretty error
                Right hookResult -> pretty hookResult.message
              & P.fold
          )
            <> hardline

      pure $
        [ preAddHookMsg
        , warnings
        , "🆕 Added task"
            <+> dquotes (pretty importTask.task.body)
            <+> "with id"
            <+> dquotes (pretty importTask.task.ulid)
        , postAddHookMsg
        ]
          & P.filter (\d -> show d /= T.empty)
          & vsep
    ---
    _ -> pure "Task could not be added"


logTask :: Config -> Connection -> [Text] -> IO (Doc AnsiStyle)
logTask conf connection bodyWords = do
  (ulid, modified_utc, effectiveUserName) <- getTriple conf
  let
    (body, extractedTags, dueUtcMb, createdUtcMb) = parseTaskBody bodyWords
    tags = extractedTags <> ["log"]
    task =
      emptyTask
        { Task.ulid = T.toLower $ show $ case createdUtcMb of
            Nothing -> ulid
            Just createdUtc -> setDateTime ulid createdUtc
        , Task.body = body
        , Task.state = Just Done
        , Task.due_utc = dueUtcMb
        , Task.closed_utc = Just modified_utc
        , Task.user = T.pack effectiveUserName
        , Task.modified_utc = modified_utc
        }

  insertRecord "tasks" connection task
  warnings <- insertTags conf connection Nothing task tags
  pure $
    warnings
      <$$> "📝 Logged task"
      <+> dquotes (pretty task.body)
      <+> "with id"
      <+> dquotes (pretty task.ulid)


execWithTask ::
  Config ->
  Connection ->
  Text ->
  (Task -> IO (Doc AnsiStyle)) ->
  IO (Doc AnsiStyle)
execWithTask conf connection idSubstr callback = do
  tasks <-
    query
      connection
      ( Query $
          "SELECT *\n"
            <> ("FROM \"" <> conf.tableName <> "\"\n")
            <> "WHERE ulid LIKE ?\n"
      )
      ["%" <> idSubstr :: Text] ::
      IO [Task]

  let
    numOfTasks = P.length tasks
    ulidLength = 26
    prefix = if T.length idSubstr == ulidLength then "" else "…"
    quote = dquotes . pretty

  if
    | numOfTasks == 0 ->
        pure $
          "⚠️  Task" <+> quote (prefix <> idSubstr) <+> "does not exist"
    | numOfTasks == 1 ->
        callback $ fromMaybe emptyTask $ P.head tasks
    | numOfTasks > 1 ->
        pure $
          "⚠️  Id slice"
            <+> quote idSubstr
            <+> "is not unique."
            <+> "It could refer to one of the following tasks:"
              <++> P.foldMap
                ( \task ->
                    annotate conf.idStyle (pretty task.ulid)
                      <++> pretty task.body
                      <> hardline
                )
                tasks
    | otherwise -> pure "This case should not be possible"


-- | Set state and automatically sets `closed_utc` via an SQL trigger
setClosedWithState :: Connection -> Task -> Maybe TaskState -> IO ()
setClosedWithState connection task theTaskState = do
  executeNamed
    connection
    [sql|
      UPDATE tasks
      SET
        state = :state,
        review_utc = NULL
      WHERE
        ulid == :ulid AND
        (state IS NULL OR state != :state)
    |]
    [ ":ulid" := task.ulid
    , ":state" := theTaskState
    ]


setReadyUtc ::
  Config -> Connection -> DateTime -> [IdText] -> IO (Doc AnsiStyle)
setReadyUtc conf connection datetime ids = do
  let utcText = T.pack $ timePrint conf.utcFormat datetime

  docs <- forM ids $ \idSubstr ->
    execWithTask conf connection idSubstr $ \task -> do
      updateTask connection $ task{Task.ready_utc = Just utcText}

      let
        prettyBody = dquotes $ pretty task.body
        prettyId = dquotes $ pretty task.ulid

      pure $
        "📅 Set ready UTC of task"
          <+> prettyBody
          <+> "with id"
          <+> prettyId
          <+> "to"
          <+> dquotes (pretty utcText)

  pure $ vsep docs


waitFor ::
  Config -> Connection -> Iso.Duration -> [Text] -> IO (Doc AnsiStyle)
waitFor conf connection duration ids = do
  docs <- forM ids $ \idSubstr ->
    execWithTask conf connection idSubstr $ \task -> do
      now <- timeCurrentP
      let
        nowAsText = (T.pack . timePrint conf.utcFormat) now
        threeDays =
          (T.pack . timePrint conf.utcFormat)
            ( utcTimeToDateTime $
                Iso.addDuration duration $
                  dateTimeToUtcTime $
                    timeFromElapsedP now
            )

      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET
            waiting_utc = :waiting_utc,
            review_utc = :review_utc
          WHERE
            ulid == :ulid
        |]
        [ ":ulid" := task.ulid
        , ":waiting_utc" := nowAsText
        , ":review_utc" := threeDays
        ]

      let
        prettyBody = dquotes $ pretty task.body
        prettyId = dquotes $ pretty task.ulid

      numOfChanges <- changes connection

      pure $
        if numOfChanges == 0
          then
            "⚠️  An error occurred while moving task"
              <+> prettyBody
                <> "with id"
              <+> prettyId
              <+> "into waiting mode"
          else
            "⏳  Set waiting UTC and review UTC for task"
              <+> prettyBody
              <+> "with id"
              <+> prettyId

  pure $ vsep docs


waitTasks :: Config -> Connection -> [Text] -> IO (Doc AnsiStyle)
waitTasks conf connection =
  waitFor conf connection $
    Iso.DurationDate (Iso.DurDateDay (Iso.DurDay 3) Nothing)


reviewTasksIn ::
  Config ->
  Connection ->
  Iso.Duration ->
  [Text] ->
  IO (Doc AnsiStyle)
reviewTasksIn conf connection duration ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      now <- timeCurrentP
      let
        xDays =
          (T.pack . timePrint conf.utcFormat)
            ( utcTimeToDateTime $
                Iso.addDuration duration $
                  dateTimeToUtcTime $
                    timeFromElapsedP now
            )
        prettyBody = dquotes $ pretty task.body
        prettyId = dquotes $ pretty task.ulid
        warningStart = "⚠️  Task" <+> prettyBody <+> "with id" <+> prettyId

      if isJust task.closed_utc
        then pure $ warningStart <+> "is already closed"
        else do
          executeNamed
            connection
            [sql|
              UPDATE tasks
              SET review_utc = :review_utc
              WHERE ulid == :ulid
            |]
            [ ":ulid" := task.ulid
            , ":review_utc" := xDays
            ]

          numOfChanges <- changes connection

          pure $
            if numOfChanges == 0
              then warningStart <+> "could not be reviewed"
              else getResultMsg task "🔎 Finished review"

  pure $ vsep docs


showDateTime :: Config -> DateTime -> Text
showDateTime conf =
  T.pack . timePrint conf.utcFormat


showEither :: Config -> Either a UTCTime -> Maybe Text
showEither conf theEither =
  theEither
    & either (const Nothing) Just
    <&> (utcTimeToDateTime >>> showDateTime conf)


-- TODO: Eliminate code duplication with createNextRecurrence
createNextRepetition ::
  Config -> Connection -> Task -> IO (Maybe (Doc AnsiStyle))
createNextRepetition conf connection task = do
  newUlidText <- formatUlid getULID
  let
    nowMb =
      ulidTextToDateTime newUlidText

    durTextEither =
      maybeToEither
        "Task has no repetition duration"
        task.repetition_duration

    isoDurEither =
      durTextEither
        <&> encodeUtf8
          >>= Iso.parseDuration

    nextDueMb =
      liftA2
        Iso.addDuration
        isoDurEither
        ( maybeToEither
            "ULID can't be converted to UTC time"
            (nowMb <&> dateTimeToUtcTime)
        )

    newTask =
      task
        { Task.ulid = newUlidText
        , Task.due_utc = nextDueMb & showEither conf
        , Task.closed_utc = Nothing
        , Task.state = Nothing
        , Task.awake_utc =
            liftA2
              Iso.addDuration
              isoDurEither
              ( maybeToEither
                  "Task has no awake UTC"
                  (task.awake_utc >>= parseUtc <&> dateTimeToUtcTime)
              )
              & showEither conf
        , Task.ready_utc =
            liftA2
              Iso.addDuration
              isoDurEither
              ( maybeToEither
                  "Task has no ready UTC"
                  (task.ready_utc >>= parseUtc <&> dateTimeToUtcTime)
              )
              & showEither conf
        , Task.modified_utc =
            nowMb
              <&> (timePrint conf.utcFormat >>> T.pack)
              & fromMaybe ""
        , Task.review_utc = Nothing
        }

  insertRecord "tasks" connection newTask

  tags <-
    query
      connection
      [sql|
        SELECT tag
        FROM task_to_tag
        WHERE task_ulid == ?
      |]
      (Only task.ulid)

  warnings <-
    liftIO $ insertTags conf connection Nothing newTask (tags & P.concat)

  liftIO $
    pure $
      Just $
        warnings
          <$$> "➡️ Created next task"
          <+> dquotes (pretty newTask.body)
          <+> "in repetition series"
          <+> dquotes (pretty newTask.group_ulid)
          <+> "with id"
          <+> dquotes (pretty newUlidText)


-- TODO: Eliminate code duplication with createNextRepetition
createNextRecurrence ::
  Config -> Connection -> Task -> IO (Maybe (Doc AnsiStyle))
createNextRecurrence conf connection task = do
  newUlidText <- formatUlid getULID
  let
    dueUtcMb =
      task.due_utc >>= parseUtc

    durTextEither =
      maybeToEither
        "Task has no recurrence duration"
        task.recurrence_duration

    isoDurEither =
      durTextEither
        <&> encodeUtf8
          >>= Iso.parseDuration

    -- If task has no due UTC, use current UTC as the base for recurrence
    nowMb = ulidTextToDateTime newUlidText
    baseDateMb = dueUtcMb <|> nowMb

    nextDueMb =
      liftA2
        Iso.addDuration
        isoDurEither
        ( maybeToEither
            "Task has no due UTC current time couldn't be determined"
            (baseDateMb <&> dateTimeToUtcTime)
        )

    newTask =
      task
        { Task.ulid = newUlidText
        , Task.due_utc = nextDueMb & showEither conf
        , Task.closed_utc = Nothing
        , Task.state = Nothing
        , Task.awake_utc =
            liftA2
              Iso.addDuration
              isoDurEither
              ( maybeToEither
                  "Task has no awake UTC"
                  (task.awake_utc >>= parseUtc <&> dateTimeToUtcTime)
              )
              & showEither conf
        , Task.ready_utc =
            liftA2
              Iso.addDuration
              isoDurEither
              ( maybeToEither
                  "Task has no ready UTC"
                  (task.ready_utc >>= parseUtc <&> dateTimeToUtcTime)
              )
              & showEither conf
        , Task.modified_utc =
            newUlidText
              & ulidTextToDateTime
              <&> (timePrint conf.utcFormat >>> T.pack)
              & fromMaybe ""
        , Task.review_utc = Nothing
        }

  insertRecord "tasks" connection newTask

  tags <-
    query
      connection
      [sql|
        SELECT tag
        FROM task_to_tag
        WHERE task_ulid == ?
      |]
      (Only task.ulid)

  warnings <-
    liftIO $ insertTags conf connection Nothing newTask (tags & P.concat)

  liftIO $
    pure $
      Just $
        warnings
          <$$> "➡️ Created next task"
          <+> dquotes (pretty task.body)
          <+> "in recurrence series"
          <+> dquotes (pretty task.group_ulid)
          <+> "with id"
          <+> dquotes (pretty newUlidText)


doTasks :: Config -> Connection -> Maybe [Text] -> [Text] -> IO (Doc AnsiStyle)
doTasks conf connection noteWordsMaybe ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      let
        prettyBody = dquotes $ pretty task.body
        prettyId = dquotes $ pretty task.ulid

      if isJust task.closed_utc
        then
          pure $
            "⚠️  Task"
              <+> prettyBody
              <+> "with id"
              <+> prettyId
              <+> "is already done"
        else do
          logMessageMaybe <-
            if isJust task.repetition_duration
              then createNextRepetition conf connection task
              else
                if isJust task.recurrence_duration
                  then createNextRecurrence conf connection task
                  else pure Nothing

          noteMessageMaybe <- case noteWordsMaybe of
            Nothing -> pure Nothing
            Just noteWords ->
              liftIO $
                addNote conf connection (unwords noteWords) ids <&> Just

          setClosedWithState connection task $ Just Done

          pure $
            fromMaybe "" (noteMessageMaybe <&> (<> hardline))
              <> ( "✅ Finished task"
                    <+> dquotes (pretty task.body)
                    <+> "with id"
                    <+> dquotes (pretty task.ulid)
                 )
              <> fromMaybe "" (logMessageMaybe <&> (hardline <>))

  pure $ vsep docs


endTasks :: Config -> Connection -> Maybe [Text] -> [Text] -> IO (Doc AnsiStyle)
endTasks conf connection noteWordsMaybe ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      let
        prettyBody = dquotes $ pretty task.body
        prettyId = dquotes $ pretty task.ulid

      if isJust task.closed_utc
        then
          pure $
            "⚠️  Task"
              <+> prettyBody
              <+> "with id"
              <+> prettyId
              <+> "is already marked as obsolete"
        else do
          logMessageMaybe <-
            if isJust task.repetition_duration
              then createNextRepetition conf connection task
              else
                if isJust task.recurrence_duration
                  then createNextRecurrence conf connection task
                  else pure Nothing

          noteMessageMaybe <- case noteWordsMaybe of
            Nothing -> pure Nothing
            Just noteWords ->
              liftIO $
                addNote conf connection (unwords noteWords) ids <&> Just

          setClosedWithState connection task $ Just Obsolete

          pure $
            fromMaybe "" (noteMessageMaybe <&> (<> hardline))
              <> ( "⏹  Marked task"
                    <+> prettyBody
                    <+> "with id"
                    <+> prettyId
                    <+> "as obsolete"
                 )
              <> fromMaybe "" (logMessageMaybe <&> (hardline <>))

  pure $ vsep docs


trashTasks :: Config -> Connection -> [Text] -> IO (Doc AnsiStyle)
trashTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      setClosedWithState connection task $ Just Deletable
      numOfChanges <- changes connection

      let
        prettyBody = dquotes $ pretty task.body
        prettyId = dquotes $ pretty task.ulid

      pure $
        if numOfChanges == 0
          then
            "⚠️  Task"
              <+> prettyBody
              <+> "with id"
              <+> prettyId
              <+> "is already marked as deletable"
          else
            "🗑  Marked task"
              <+> prettyBody
              <+> "with id"
              <+> prettyId
              <+> "as deletable"

  pure $ vsep docs


deleteTasks :: Config -> Connection -> [Text] -> IO (Doc AnsiStyle)
deleteTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      execute
        connection
        [sql|
          DELETE FROM tasks
          WHERE ulid == ?
        |]
        (Only task.ulid)

      execute
        connection
        [sql|
          DELETE FROM task_to_tag
          WHERE task_ulid == ?
        |]
        (Only task.ulid)

      execute
        connection
        [sql|
          DELETE FROM task_to_note
          WHERE task_ulid == ?
        |]
        (Only task.ulid)

      let
        prettyBody = dquotes $ pretty task.body
        prettyId = dquotes $ pretty task.ulid

      pure $ "❌ Deleted task" <+> prettyBody <+> "with id" <+> prettyId

  pure $ vsep docs


durationToIso :: Duration -> Text
durationToIso dur =
  "PT" <> show (coerce (durationMinutes dur) :: Int64) <> "M"


repeatTasks ::
  Config -> Connection -> Iso.Duration -> [IdText] -> IO (Doc AnsiStyle)
repeatTasks conf connection duration ids = do
  let durationIsoText = decodeUtf8 $ Iso.formatDuration duration

  docs <- forM ids $ \idSubstr ->
    execWithTask conf connection idSubstr $ \task -> do
      groupUlid <- formatUlid getULID

      recDur :: [Only SQLData] <-
        queryNamed
          connection
          [sql|
            UPDATE tasks
            SET
              repetition_duration = :repetition_duration,
              group_ulid = :group_ulid
            WHERE
              ulid == :ulid AND
              recurrence_duration IS NULL
            RETURNING recurrence_duration
          |]
          [ ":repetition_duration" := durationIsoText
          , ":group_ulid" := groupUlid
          , ":ulid" := task.ulid
          ]

      if recDur /= [Only SQLNull]
        then
          pure $
            "⚠️ Task"
              <+> dquotes (pretty task.body)
              <+> "with id"
              <+> dquotes (pretty task.ulid)
              <+> "is already in a recurrence series"
        else do
          -- If repetition is set for already closed task,
          -- next task in series must be created immediately
          creationMb <-
            if isNothing task.closed_utc
              then pure $ Just mempty
              else
                liftIO $
                  createNextRepetition conf connection $
                    task
                      { Task.repetition_duration = Just durationIsoText
                      , Task.group_ulid = Just groupUlid
                      }

          pure $
            "📅 Set repeat duration of task"
              <+> dquotes (pretty task.body)
              <+> "with id"
              <+> dquotes (pretty task.ulid)
              <+> "to"
              <+> dquotes (pretty durationIsoText)
                <++> ( creationMb
                        & fromMaybe
                          "⚠️ Next task in repetition series could not be created!"
                     )

  pure $ vsep docs


recurTasks ::
  Config -> Connection -> Iso.Duration -> [IdText] -> IO (Doc AnsiStyle)
recurTasks conf connection duration ids = do
  let durationIsoText = decodeUtf8 $ Iso.formatDuration duration

  docs <- forM ids $ \idSubstr ->
    execWithTask conf connection idSubstr $ \task -> do
      groupUlid <- formatUlid getULID

      repDur :: [Only SQLData] <-
        queryNamed
          connection
          [sql|
            UPDATE tasks
            SET
              recurrence_duration = :recurrence_duration,
              group_ulid = :group_ulid
            WHERE
              ulid == :ulid AND
              repetition_duration IS NULL
            RETURNING repetition_duration
          |]
          [ ":recurrence_duration" := durationIsoText
          , ":group_ulid" := groupUlid
          , ":ulid" := task.ulid
          ]

      if repDur /= [Only SQLNull]
        then
          pure $
            "⚠️ Task"
              <+> dquotes (pretty task.body)
              <+> "with id"
              <+> dquotes (pretty task.ulid)
              <+> "is already in a repetition series"
        else do
          -- If recurrence is set for already closed task,
          -- next task in series must be created immediately
          creationMb <-
            if isNothing task.closed_utc
              then pure $ Just mempty
              else
                liftIO $
                  createNextRecurrence conf connection $
                    task
                      { Task.recurrence_duration = Just durationIsoText
                      , Task.group_ulid = Just groupUlid
                      }

          pure $
            "📅 Set recurrence duration of task"
              <+> dquotes (pretty task.body)
              <+> "with id"
              <+> dquotes (pretty task.ulid)
              <+> "to"
              <+> dquotes (pretty durationIsoText)
                <++> ( creationMb
                        & fromMaybe
                          "⚠️ Next task in recurrence series could not be created!"
                     )

  pure $ vsep docs


adjustPriority :: Config -> Float -> [IdText] -> IO (Doc AnsiStyle)
adjustPriority conf adjustment ids = do
  dbPath <- getDbPath conf
  withConnection dbPath $ \connection -> do
    docs <- forM ids $ \idSubstr -> do
      execWithTask conf connection idSubstr $ \task -> do
        executeNamed
          connection
          [sql|
            UPDATE tasks
            SET priority_adjustment =
              ifnull(priority_adjustment, 0) + :adjustment
            WHERE ulid == :ulid
          |]
          [ ":adjustment" := adjustment
          , ":ulid" := task.ulid
          ]

        numOfChanges <- changes connection

        let prettyBody = dquotes $ pretty task.body

        pure $
          if numOfChanges == 0
            then
              "⚠️ An error occurred while adjusting the priority of task"
                <+> prettyBody
            else
              (if adjustment > 0 then "⬆️  Increased" else "⬇️  Decreased")
                <+> "priority of task"
                <+> prettyBody
                <+> "with id"
                <+> dquotes (pretty task.ulid)
                <+> "by"
                <+> pretty (abs adjustment)

    pure $ vsep docs


startTasks :: Config -> Connection -> [Text] -> IO (Doc AnsiStyle)
startTasks conf connection ids = do
  logMessage <- addNote conf connection "start" ids

  pure $
    pretty $
      T.replace
        "📝  Added a note to"
        "⏳ Started"
        (show logMessage)


stopTasks :: Config -> Connection -> [Text] -> IO (Doc AnsiStyle)
stopTasks conf connection ids = do
  logMessages <- addNote conf connection "stop" ids

  pure $
    pretty $
      T.replace
        "📝  Added a note to"
        "⌛️ Stopped"
        (show logMessages)


formatTaskForInfo ::
  Config ->
  DateTime ->
  (FullTask, [TaskToTag], [TaskToNote]) ->
  Doc AnsiStyle
formatTaskForInfo conf now (taskV, tags, notes) =
  let
    mkGreen = annotate (colr conf Green)
    grayOut = annotate (colrDull conf Black)
    stateHierarchy = getStateHierarchy now $ cpTimesAndState taskV
    mbCreatedUtc =
      fmap
        (T.pack . timePrint (utcFormat defaultConfig))
        (ulidTextToDateTime taskV.ulid)
    tagsPretty =
      tags
        <&> ( \t ->
                annotate conf.tagStyle (pretty t.tag)
                  <++> maybe
                    mempty
                    (grayOut . pretty . T.pack . timePrint conf.utcFormat)
                    (ulidTextToDateTime t.ulid)
                  <++> grayOut (pretty t.ulid)
            )
    notesPretty =
      notes
        <&> ( \n ->
                maybe
                  mempty
                  (grayOut . pretty . T.pack . timePrint conf.utcFormat)
                  (ulidTextToDateTime n.ulid)
                  <++> grayOut (pretty n.ulid)
                  <> hardline
                  <> indent 2 (pretty n.note)
                  <> hardline
            )

    mbAwakeUtc = FullTask.awake_utc taskV
    mbReadyUtc = FullTask.ready_utc taskV
    mbWaitingUtc = FullTask.waiting_utc taskV
    mbReviewUtc = FullTask.review_utc taskV
    mbDueUtc = FullTask.due_utc taskV
    mbClosedUtc = FullTask.closed_utc taskV
    mbModifiedUtc = Just $ FullTask.modified_utc taskV

    printIf :: Doc AnsiStyle -> Maybe Text -> Maybe (Doc AnsiStyle)
    printIf name =
      fmap
        ( \v ->
            name
              <+> annotate conf.dueStyle (pretty v)
                <> hardline
        )
  in
    hardline
      <> annotate bold (pretty taskV.body)
      <> hardline
      <> hardline
      <> ( if P.null tags
            then mempty
            else
              (tags <&> (TaskToTag.tag >>> formatTag conf) & hsep)
                <> hardline
                <> hardline
         )
      <> ( if P.null notes
            then mempty
            else
              ( notes
                  <&> ( \n ->
                          maybe
                            mempty
                            ( grayOut
                                . pretty
                                . T.pack
                                . timePrint
                                  conf.utcFormatShort
                            )
                            (ulidTextToDateTime n.ulid)
                            <++> align (pretty n.note)
                      )
                  & vsep
              )
                <> hardline
                <> hardline
         )
      <> ( "   State:"
            <+> mkGreen (pretty stateHierarchy)
              <> hardline
         )
      <> ( "Priority:"
            <+> annotate
              conf.priorityStyle
              (pretty $ FullTask.priority taskV)
              <> hardline
         )
      <> ( "    ULID:"
            <+> grayOut (pretty $ FullTask.ulid taskV)
              <> hardline
         )
      <> hardline
      <> ( [ (printIf "🆕  Created  ", mbCreatedUtc)
           , (printIf "☀️   Awake   ", mbAwakeUtc)
           , (printIf "📅   Ready   ", mbReadyUtc)
           , (printIf "⏳  Waiting  ", mbWaitingUtc)
           , (printIf "🔎  Review   ", mbReviewUtc)
           , (printIf "📅    Due    ", mbDueUtc)
           , (printIf "✅   Done    ", mbClosedUtc)
           , (printIf "✏️   Modified ", mbModifiedUtc)
           ]
            & sortBy (compare `on` snd)
            & P.mapMaybe (\tup -> fst tup (snd tup))
            & punctuate (pretty ("       ⬇" :: Text))
            & vsep
         )
      <> hardline
      <> maybe
        mempty
        ( \value ->
            "Repetition Duration:"
              <+> mkGreen (pretty value)
                <> hardline
        )
        (FullTask.repetition_duration taskV)
      <> maybe
        mempty
        ( \value ->
            "Recurrence Duration:"
              <+> mkGreen (pretty value)
                <> hardline
        )
        (FullTask.recurrence_duration taskV)
      <> maybe
        mempty
        ( \value ->
            "Group Ulid:"
              <+> grayOut (pretty value)
                <> hardline
        )
        (FullTask.group_ulid taskV)
      <> ( "User:"
            <+> mkGreen (pretty $ FullTask.user taskV)
              <> hardline
         )
      <> hardline
      <> maybe
        mempty
        ( \value ->
            "Metadata:"
              <> hardline
              <> indent 2 (pretty $ decodeUtf8 $ Yaml.encode value)
              <> hardline
        )
        (FullTask.metadata taskV)
      <> ( if P.null tags
            then mempty
            else
              annotate underlined "Tags Detailed:"
                <> hardline
                <> hardline
                <> vsep tagsPretty
                <> hardline
                <> hardline
         )
      <> ( if P.null notes
            then mempty
            else
              annotate underlined "Notes Detailed:"
                <> hardline
                <> hardline
                <> vsep notesPretty
                <> hardline
         )


infoTask :: Config -> Connection -> Text -> IO (Doc AnsiStyle)
infoTask conf connection idSubstr = do
  execWithTask conf connection idSubstr $ \task -> do
    now <- dateCurrent

    fullTasks :: [FullTask] <-
      query
        connection
        [sql|
          SELECT *
          FROM tasks_view
          WHERE ulid == ?
        |]
        (Only task.ulid)

    tags <-
      query
        connection
        [sql|
          SELECT *
          FROM task_to_tag
          WHERE task_ulid == ?
        |]
        (Only task.ulid)

    notes <-
      query
        connection
        [sql|
          SELECT *
          FROM task_to_note
          WHERE task_ulid == ?
        |]
        (Only task.ulid)

    pure $ case fullTasks of
      [fullTask] -> formatTaskForInfo conf now (fullTask, tags, notes)
      _ -> pretty noTasksWarning


nextTask :: Config -> Connection -> IO (Doc AnsiStyle)
nextTask conf connection = do
  now <- dateCurrent

  tasks :: [FullTask] <-
    query_
      connection
      [sql|
        SELECT *
        FROM tasks_view
        WHERE closed_utc IS NULL
        ORDER BY priority DESC
        LIMIT 1
      |]

  case tasks of
    [fullTask] -> do
      tags <-
        query
          connection
          [sql|
            SELECT *
            FROM task_to_tag
            WHERE task_ulid == ?
          |]
          (Only fullTask.ulid)

      notes <-
        query
          connection
          [sql|
            SELECT *
            FROM task_to_note
            WHERE task_ulid == ?
          |]
          (Only fullTask.ulid)

      pure $ formatTaskForInfo conf now (fullTask, tags, notes)
    _ ->
      pure $ pretty noTasksWarning


randomTask ::
  Config -> Connection -> Maybe [Text] -> IO (Doc AnsiStyle)
randomTask conf connection filterExpression = do
  let
    parserResults =
      readP_to_S filterExpsParser $
        T.unpack (unwords $ fromMaybe [""] filterExpression)
    filterMay = listToMaybe parserResults

  case filterMay of
    Nothing -> do
      (tasks :: [FullTask]) <-
        query_
          connection
          [sql|
            SELECT *
            FROM tasks_view
            WHERE closed_utc IS NULL
            ORDER BY random()
            LIMIT 1
          |]

      case tasks of
        [fullTask] -> infoTask conf connection fullTask.ulid
        _ -> pure $ pretty noTasksWarning
    --
    Just (filterExps, _) -> do
      let
        ppInvalidFilter = \case
          (InvalidFilter error) ->
            dquotes (pretty error) <+> "is an invalid filter"
          (HasStatus Nothing) -> "Filter contains an invalid state value"
          _ -> "The functions should not be called with a valid function"
        errors = P.filter (not . isValidFilter) filterExps
        errorsDoc =
          if P.length errors > 0
            then
              Just $
                vsep (fmap (annotate (colr conf Red) . ppInvalidFilter) errors)
                  <> hardline
                  <> hardline
            else Nothing

      tasks <-
        query_ connection $
          getFilterQuery
            (HasStatus (Just IsOpen) : filterExps)
            (Just "random()")
            Nothing

      case P.headMay (tasks :: [FullTask]) of
        Nothing -> pure $ pretty noTasksWarning
        Just fullTask -> do
          taskFormatted <- infoTask conf connection fullTask.ulid
          pure $ errorsDoc & fromMaybe taskFormatted


findTask :: Config -> Connection -> Text -> IO (Doc AnsiStyle)
findTask conf connection aPattern = do
  tasks :: [(Text, Text, Maybe [Text], Maybe [Text], Maybe Text)] <-
    query_
      connection
      [sql|
        SELECT ulid, body, tags, notes, metadata
        FROM tasks_view
      |]

  let
    ulidWidth = 5
    numOfResults = 8
    minimumScore = 5
    ulidColor = Green
    -- TODO: Escape sequences are counted as several chars and mess up wrapping.
    --       Implement first: https://github.com/ad-si/Fuzzily/issues/1
    preTag = "\x1b[4m\x1b[34m" -- Set color to blue and underline text
    postTag = "\x1b[0m" -- Reset styling
    metaNorm metadata =
      metadata
        & fromMaybe ""
        & T.replace ",\"" ", \""
        & T.replace "\":" "\": "
        & T.replace "\"" ""
    matchFunc =
      Fuzzily.match
        Fuzzily.IgnoreCase
        (preTag, postTag)
        identity
        aPattern

    -- Calculate fuzzy score for each part individually
    -- and pick the highest one
    scoreFunc (ulid, theBody, _, mbNotes, mbMetadata) =
      let
        scoreParts =
          [ ( matchFunc theBody
                -- Weight the body score higher
                <&> \case
                  Fuzzily.Fuzzy{score, ..} ->
                    Fuzzily.Fuzzy{score = score + 1, ..}
            )
              -- Always include the body
              <|> ( Just $
                      Fuzzily.Fuzzy
                        { original = theBody
                        , rendered = theBody
                        , score = 0
                        }
                  )
          , matchFunc (maybe "" unwords mbNotes)
          , -- TODO: Find good way to include tags
            -- , matchFunc (maybe "" unwords mbTags)
            matchFunc (metaNorm mbMetadata)
          , matchFunc ulid
          ]
        highestScore = P.maximum $ 0 : (catMaybes scoreParts <&> Fuzzily.score)
        combinedText =
          scoreParts
            & catMaybes
            <&> (Fuzzily.rendered >>> reflow)
            & P.intersperse mempty
            & vcat
      in
        (highestScore, ulid, combinedText)

    fstOf3 (x, _, _) = x
    tasksScored =
      tasks
        <&> scoreFunc
        & P.filter ((> minimumScore) . fstOf3)
        & sortOn (Down . fstOf3)
    moreResults = P.length tasksScored - numOfResults
    header =
      annotate (underlined <> color ulidColor) (fill ulidWidth "ULID")
        <++> annotate underlined (fill 20 "Task")
        <> hardline
    body =
      tasksScored
        & P.take numOfResults
        <&> ( \(_, ulid, combinedText) ->
                annotate
                  (colr conf ulidColor)
                  (fill ulidWidth $ pretty $ T.takeEnd ulidWidth ulid)
                  <> indent 2 combinedText
            )
        & P.intersperse mempty
        & vsep
    footer =
      if moreResults > 0
        then
          hardline
            <> hardline
            <> annotate
              (colr conf Red)
              ("There are " <> pretty moreResults <> " more results available")
            <> hardline
        else hardline

  pure $ header <> body <> footer


-- TODO: Use Continuation monad to avoid callback hell
-- withConnectCont :: Text -> ContT a IO Connection
-- withConnectCont dbPath =
--     ContT $ withConnection dbPath

addTag :: Config -> Connection -> Text -> [IdText] -> IO (Doc AnsiStyle)
addTag conf conn tag ids = do
  let tagNorm = T.dropWhile (== '+') tag
  docs <- forM ids $ \idSubstr ->
    execWithTask conf conn idSubstr $ \task -> do
      now <- fmap (T.pack . timePrint conf.utcFormat) timeCurrentP
      ulid <- formatUlid getULID

      catchIf
        -- TODO: Find out why it's not `ErrorConstraintUnique`
        (\(err :: SQLError) -> err.sqlError == ErrorConstraint)
        ( do
            insertRecord
              "task_to_tag"
              conn
              TaskToTag{ulid, task_ulid = task.ulid, tag = tagNorm}

            -- TODO: Check if modified_utc could be set via SQL trigger
            executeNamed
              conn
              [sql|
                UPDATE tasks
                SET modified_utc = :now
                WHERE ulid == :ulid
              |]
              [ ":now" := now
              , ":ulid" := task.ulid
              ]

            let
              prettyBody = dquotes $ pretty task.body
              prettyId = dquotes $ pretty task.ulid

            pure $
              "🏷  Added tag"
                <+> dquotes (pretty tagNorm)
                <+> "to task"
                <+> prettyBody
                <+> "with id"
                <+> prettyId
        )
        (handleTagDupError conf tagNorm)

  pure $ vsep docs


deleteTag :: Config -> Connection -> Text -> [IdText] -> IO (Doc AnsiStyle)
deleteTag conf connection tag ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          DELETE FROM task_to_tag
          WHERE
            task_ulid == :task_ulid
            AND tag == :tag
        |]
        [ ":task_ulid" := task.ulid
        , ":tag" := tag
        ]

      numOfChanges <- changes connection

      pure $
        if numOfChanges == 0
          then
            annotate (colr conf Yellow) $
              "⚠️  Tag"
                <+> dquotes (pretty tag)
                <+> "is not set for task"
                <+> dquotes (pretty task.ulid)
          else getResultMsg task ("💥 Removed tag \"" <> pretty tag <> "\"")

  pure $ vsep docs


addNote :: Config -> Connection -> Text -> [IdText] -> IO (Doc AnsiStyle)
addNote conf connection noteBody ids = do
  docs <- forM ids $ \idSubstr ->
    execWithTask conf connection idSubstr $ \task -> do
      now <- timeCurrentP <&> (timePrint conf.utcFormat >>> T.pack)
      ulid <- formatUlid getULID

      insertRecord
        "task_to_note"
        connection
        TaskToNote
          { ulid
          , task_ulid = task.ulid
          , TaskToNote.note = noteBody
          }

      -- TODO: Check if modified_utc could be set via SQL trigger
      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET modified_utc = :now
          WHERE ulid == :ulid
        |]
        [ ":now" := now
        , ":ulid" := task.ulid
        ]

      let
        prettyBody = dquotes $ pretty task.body
        prettyId = dquotes $ pretty task.ulid

      pure $
        "🗒  Added a note to task"
          <+> prettyBody
          <+> "with id"
          <+> prettyId

  pure $ vsep docs


deleteNote :: Config -> Connection -> IdText -> IO (Doc AnsiStyle)
deleteNote conf connection noteId = do
  taskIds :: [Only Text] <-
    queryNamed
      connection
      [sql|
        DELETE FROM task_to_note
        WHERE ulid == :noteId
        RETURNING task_ulid
      |]
      [":noteId" := noteId]

  case taskIds of
    [Only taskId] ->
      pure $
        "💥 Deleted note"
          <+> dquotes (pretty noteId)
          <+> "of task"
          <+> dquotes (pretty taskId)
    [] ->
      pure $
        annotate (colr conf Yellow) $
          "⚠️  Note" <+> dquotes (pretty noteId) <+> "does not exist"
    _ ->
      pure $
        annotate (colr conf Yellow) $
          ("⚠️  Note" <+> dquotes (pretty noteId) <+> "exists multiple times.")
            <++> "This indicates a serious database inconsistency \
                 \and you should clean up the database manually \
                 \before continuing."


setDueUtc :: Config -> Connection -> DateTime -> [IdText] -> IO (Doc AnsiStyle)
setDueUtc conf connection datetime ids = do
  let
    utcText :: Text
    utcText = T.pack $ timePrint conf.utcFormat datetime

  docs <- forM ids $ \idSubstr ->
    execWithTask conf connection idSubstr $ \task -> do
      updateTask connection task{Task.due_utc = Just utcText}

      let
        prettyBody = dquotes $ pretty task.body
        prettyId = dquotes $ pretty task.ulid

      pure $
        "📅 Set due UTC of task"
          <+> prettyBody
          <+> "with id"
          <+> prettyId
          <+> "to"
          <+> dquotes (pretty utcText)

  pure $ vsep docs


getResultMsg :: Task -> Doc AnsiStyle -> Doc AnsiStyle
getResultMsg task msg = do
  let
    prettyBody = dquotes $ pretty task.body
    prettyId = dquotes $ pretty task.ulid

  msg <+> "of task" <+> prettyBody <+> "with id" <+> prettyId


getWarnMsg :: Config -> Task -> Doc AnsiStyle -> Doc AnsiStyle
getWarnMsg conf task msg = do
  let
    prettyBody = dquotes $ pretty task.body
    prettyId = dquotes $ pretty task.ulid

  annotate (colr conf Yellow) $
    "⚠️ Task" <+> prettyBody <+> "with id" <+> prettyId <+> msg


uncloseTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
uncloseTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET
            closed_utc = NULL,
            state = NULL
          WHERE
            ulid == :ulid AND
            closed_utc IS NOT NULL AND
            state IS NOT NULL
        |]
        [":ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "is still open"
          else getResultMsg task "💥 Removed close timestamp and state field"

  pure $ vsep docs


undueTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
undueTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET due_utc = NULL
          WHERE
            ulid == :ulid AND
            due_utc IS NOT NULL
        |]
        [":ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "does not have a due timestamp"
          else getResultMsg task "💥 Removed due timestamp"

  pure $ vsep docs


unwaitTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
unwaitTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET waiting_utc = NULL
          WHERE
            ulid == :ulid AND
            waiting_utc IS NOT NULL
        |]
        [":ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "does not have a waiting timestamp"
          else getResultMsg task "💥 Removed waiting and review timestamps"

  pure $ vsep docs


unwakeTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
unwakeTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET awake_utc = NULL
          WHERE
            ulid == :ulid AND
            awake_utc IS NOT NULL
        |]
        [":ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "does not have an awake timestamp"
          else getResultMsg task "💥 Removed awake timestamp"

  pure $ vsep docs


unreadyTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
unreadyTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET ready_utc = NULL
          WHERE
            ulid == :ulid AND
            ready_utc IS NOT NULL
        |]
        [":ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "does not have a ready timestamp"
          else getResultMsg task "💥 Removed ready timestamp"

  pure $ vsep docs


unreviewTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
unreviewTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET review_utc = NULL
          WHERE
            ulid == :ulid AND
            review_utc IS NOT NULL
        |]
        [":ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "does not have a review timestamp"
          else getResultMsg task "💥 Removed review timestamp"

  pure $ vsep docs


unrepeatTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
unrepeatTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET repetition_duration = NULL
          WHERE
            ulid == :ulid AND
            repetition_duration IS NOT NULL
        |]
        [":ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "does not have a repetition duration"
          else getResultMsg task "💥 Removed repetition duration"

  pure $ vsep docs


unrecurTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
unrecurTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET recurrence_duration = NULL
          WHERE
            ulid == :ulid AND
            recurrence_duration IS NOT NULL
        |]
        [":ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "does not have a recurrence duration"
          else getResultMsg task "💥 Removed recurrence duration"

  pure $ vsep docs


untagTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
untagTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          DELETE FROM task_to_tag
          WHERE task_ulid == :task_ulid
        |]
        [":task_ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "does not have any tags"
          else getResultMsg task "💥 Removed all tags"

  pure $ vsep docs


unnoteTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
unnoteTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          DELETE FROM task_to_note
          WHERE task_ulid == :task_ulid
        |]
        [":task_ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "does not have any notes"
          else getResultMsg task "💥 Deleted all notes"

  pure $ vsep docs


unprioTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
unprioTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET priority_adjustment = NULL
          WHERE
            ulid == :ulid AND
            priority_adjustment IS NOT NULL
        |]
        [":ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "does not have a priority adjustment"
          else getResultMsg task "💥 Removed priority adjustment"

  pure $ vsep docs


unmetaTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
unmetaTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      executeNamed
        connection
        [sql|
          UPDATE tasks
          SET metadata = NULL
          WHERE
            ulid == :ulid AND
            metadata IS NOT NULL
        |]
        [":ulid" := task.ulid]

      numOfChanges <- changes connection
      pure $
        if numOfChanges == 0
          then getWarnMsg conf task "does not have any metadata"
          else getResultMsg task "💥 Removed metadata"

  pure $ vsep docs


duplicateTasks :: Config -> Connection -> [IdText] -> IO (Doc AnsiStyle)
duplicateTasks conf connection ids = do
  docs <- forM ids $ \idSubstr -> do
    execWithTask conf connection idSubstr $ \task -> do
      dupeUlid <- formatUlid getULID
      -- TODO: Check if modified_utc can be set via an SQL trigger
      modified_utc <- formatElapsedP conf timeCurrentP
      user <- getEffectiveUserName

      let dupeTask =
            emptyTask
              { Task.ulid = dupeUlid
              , Task.body = task.body
              , Task.modified_utc = modified_utc
              , Task.priority_adjustment = task.priority_adjustment
              , Task.user = T.pack user
              , Task.metadata = task.metadata
              }

      insertRecord "tasks" connection dupeTask

      tags <-
        query
          connection
          [sql|
            SELECT tag
            FROM task_to_tag
            WHERE task_ulid == ?
          |]
          (Only task.ulid)

      warnings <-
        liftIO $ insertTags conf connection Nothing dupeTask (tags & P.concat)

      notes <-
        query
          connection
          [sql|
            SELECT *
            FROM task_to_note
            WHERE task_ulid == ?
          |]
          (Only task.ulid)

      let
        noteTuples =
          notes <&> \theNote ->
            ( ulidTextToDateTime (TaskToNote.ulid theNote)
            , TaskToNote.note theNote
            )

      liftIO $
        insertNoteTuples
          connection
          dupeTask
          noteTuples

      numOfChanges <- changes connection

      let
        prettyBody = dquotes $ pretty task.body
        prettyId = dquotes $ pretty task.ulid

      pure $
        warnings
          <$$> if numOfChanges == 0
            then
              "⚠️  Task"
                <+> prettyBody
                <+> "with id"
                <+> prettyId
                <+> "could not be duplicated"
            else
              "👯  Created a duplicate of task"
                <+> prettyBody
                <+> "(id:"
                <+> pretty task.ulid
                <+> ")"
                <+> "with id"
                <+> pretty dupeUlid

  pure $ vsep docs


showAtPrecision :: Int -> Double -> Text
showAtPrecision numOfDigits number =
  let
    tuple = T.breakOn "." (show number)
    clipDecimalPart =
      if snd tuple == ".0"
        then T.replace ".0" (T.replicate (1 + numOfDigits) " ")
        else T.take (1 + numOfDigits)
  in
    fst tuple
      <> if numOfDigits /= 0
        then (clipDecimalPart . snd) tuple
        else ""


formatTag :: (Pretty a) => Config -> a -> Doc AnsiStyle
formatTag conf =
  annotate conf.tagStyle
    . (annotate (colr conf Black) "+" <>)
    . pretty


invalidUlidMsg :: FullTask -> Doc AnsiStyle
invalidUlidMsg task =
  "Id"
    <+> dquotes (pretty task.ulid)
    <+> "is an invalid ulid and could not be converted to a datetime"


-- | Convert seconds into a short, fractional notation like 1.5y, 2.3mo, …
formatDuration :: Seconds -> T.Text
formatDuration (Seconds seconds) = do
  let
    secInHour = 3600.0
    secInDay = 24 * secInHour
    secInMonth = 30 * secInDay
    secInYear = 12 * secInMonth

    years = fromIntegral seconds / secInYear
    months = fromIntegral seconds / secInMonth
    days = fromIntegral seconds / secInDay
    hours = fromIntegral seconds / secInHour

    format :: Double -> [Char] -> T.Text
    format val suffix =
      T.pack $ printf "%.1f%-2s" val suffix

  case () of
    _
      | years > 1 -> format years "y"
      | months > 1 -> format months "mo"
      | days > 1 -> format days "d"
      | otherwise -> format hours "h"


formatTaskPriority :: Config -> FullTask -> Doc AnsiStyle
formatTaskPriority conf task = do
  let
    prio = fromMaybe 0 task.priority
    txt = T.justifyRight 4 ' ' $ showAtPrecision 1 $ realToFrac prio
  annotate conf.priorityStyle (pretty txt)


formatTaskDue :: Config -> FullTask -> Doc AnsiStyle
formatTaskDue conf task = do
  let
    dueUtcMaybe = task.due_utc >>= parseUtc <&> format
    format = T.replace " 00:00:00" "" . T.pack . timePrint conf.utcFormat
  annotate conf.dueStyle (pretty dueUtcMaybe)


formatTaskClose :: Config -> FullTask -> Doc AnsiStyle
formatTaskClose conf task = do
  let closedUtcMaybe = task.closed_utc >>= parseUtc <&> timePrint conf.utcFormat
  annotate conf.closedStyle (pretty closedUtcMaybe)


formatTaskTags :: Config -> FullTask -> Doc AnsiStyle
formatTaskTags conf task = do
  let tags = fromMaybe [] task.tags
  hsep (tags <&> formatTag conf)


formatTaskNotes :: FullTask -> Doc AnsiStyle
formatTaskNotes task =
  if not $ P.null task.notes then "📝" else ""


formatTaskId :: Config -> Int -> FullTask -> Doc AnsiStyle
formatTaskId conf taskWidth task = do
  let id = pretty $ T.takeEnd taskWidth task.ulid
  annotate conf.idStyle id


formatTaskBody :: Config -> DateTime -> FullTask -> Doc AnsiStyle
formatTaskBody conf now task = pretty reviewIcon <> dueSoon <> body
  where
    dueIn offset =
      let dateMaybe = task.due_utc >>= parseUtc
      in  isJust dateMaybe && dateMaybe < Just (now `timeAdd` offset)
    grayOutIfDone doc =
      if isOpen
        then annotate conf.bodyStyle doc
        else annotate conf.bodyClosedStyle doc

    isOpen = isNothing task.closed_utc
    reviewIcon = case task.review_utc >>= parseUtc of
      Nothing -> "" :: Text
      Just date_ -> if date_ < now then "🔎 " else ""
    dueSoon = if dueIn mempty{durationHours = 24} && isOpen then "⚠️️ " else ""
    taskBody =
      reflow $
        if countChar '\n' task.body > 0
          then (task.body & T.takeWhile (/= '\n')) <> " ▼"
          else task.body
    body =
      if dueIn mempty && isOpen
        then annotate (colr conf Red) taskBody
        else grayOutIfDone taskBody


formatTaskOpenedUTC :: Config -> DateTime -> FullTask -> Doc AnsiStyle
formatTaskOpenedUTC conf _now task = do
  let
    dateMaybe = ulidTextToDateTime task.ulid
    formatTaskDate = T.pack . timePrint ISO8601_Date

  annotate (dateStyle conf) (pretty $ maybe "bad Ulid" formatTaskDate dateMaybe)


formatTaskAge :: Config -> DateTime -> FullTask -> Doc AnsiStyle
formatTaskAge conf now task = do
  let
    dateMaybe = ulidTextToDateTime task.ulid
    formatTaskDuration =
      timeDiff now
        >>> formatDuration
        >>> T.center (colToWidth conf 0 AgeCol) ' '
        >>> T.replace ".0" "  "

  annotate
    (dateStyle conf)
    (pretty $ maybe "bad Ulid" formatTaskDuration dateMaybe)


colToWidth :: Config -> Int -> Column -> Int
colToWidth conf idColWidth = \case
  IdCol -> idColWidth
  PrioCol -> conf.prioWidth
  OpenedUTCCol -> conf.dateWidth
  AgeCol -> 6
  BodyCol -> conf.bodyWidth
  EmptyCol -> 0


formatTaskLine :: Config -> DateTime -> Int -> FullTask -> Doc AnsiStyle
formatTaskLine conf now idColWidth task = do
  let
    columns = conf.columns & P.filter (/= EmptyCol)
    multilineIndent = 2
    hangWidth =
      ( (columns & P.filter (/= BodyCol) <&> colToWidth conf idColWidth)
          <> [multilineIndent]
      )
        & P.intersperse 2
        & P.sum
    hhsep = concatWith (<++>)
    isEmptyDoc doc = show doc /= ("" :: Text)
    fields =
      columns
        & P.concatMap
          ( \case
              IdCol -> [formatTaskId conf idColWidth task]
              PrioCol -> [formatTaskPriority conf task]
              OpenedUTCCol -> [formatTaskOpenedUTC conf now task]
              AgeCol -> [formatTaskAge conf now task]
              BodyCol ->
                [ formatTaskBody conf now task
                , formatTaskDue conf task
                , formatTaskClose conf task
                , formatTaskTags conf task
                , formatTaskNotes task
                ]
              EmptyCol -> []
          )
  hang hangWidth $ hhsep $ P.filter isEmptyDoc fields


getIdLength :: Float -> Int
getIdLength numOfItems =
  -- TODO: Calculate idLength by total number of tasks, not just of the viewed
  let
    targetCollisionChance = 0.01 -- Targeted likelihood of id collisions
    sizeOfAlphabet = 32 -- Crockford's base 32 alphabet
  in
    ceiling (logBase sizeOfAlphabet (numOfItems / targetCollisionChance)) + 1


countTasks :: Config -> Connection -> Maybe [Text] -> IO (Doc AnsiStyle)
countTasks conf connection filterExpression = do
  let
    parserResults =
      readP_to_S filterExpsParser $
        T.unpack (unwords $ fromMaybe [""] filterExpression)
    filterMay = listToMaybe parserResults

  case filterMay of
    Nothing -> do
      [NumRows taskCount] <-
        query_ connection $
          Query $
            "SELECT count(1) FROM " <> quoteKeyword conf.tableName

      pure $ pretty taskCount
    Just (filterExps, _) -> do
      let
        ppInvalidFilter = \case
          (InvalidFilter error) ->
            dquotes (pretty error) <+> "is an invalid filter"
          (HasStatus Nothing) -> "Filter contains an invalid state value"
          _ -> "The functions should not be called with a valid function"
        errors = P.filter (not . isValidFilter) filterExps
        errorsDoc =
          if P.length errors > 0
            then
              Just $
                vsep (fmap (annotate (colr conf Red) . ppInvalidFilter) errors)
                  <> hardline
                  <> hardline
            else Nothing

      tasks <- query_ connection (getFilterQuery filterExps Nothing Nothing)
      pure $ errorsDoc & fromMaybe (pretty $ P.length (tasks :: [FullTask]))


-- TODO: Print number of remaining tasks and how to display them at the bottom
headTasks :: Config -> DateTime -> Connection -> Maybe Int -> IO (Doc AnsiStyle)
headTasks conf now connection availableLinesMb = do
  let taskCount = do
        let availableLines = availableLinesMb & fromMaybe 0
        if P.isJust availableLinesMb && availableLines < conf.headCount
          then availableLines
          else conf.headCount

  tasks <-
    queryNamed
      connection
      -- TODO: Add `wait_utc` < datetime('now')"
      [sql|
        SELECT *
        FROM tasks_view
        WHERE closed_utc IS NULL
        ORDER BY
          priority DESC,
          due_utc ASC,
          ulid DESC
        LIMIT :taskCount
      |]
      [":taskCount" := taskCount]

  formatTasksColor conf now (isJust availableLinesMb) tasks


newTasks ::
  Config ->
  DateTime ->
  Connection ->
  Maybe [Text] ->
  Maybe Int ->
  IO (Doc AnsiStyle)
newTasks conf now connection filterExp availableLinesMb = do
  let
    parserResults =
      readP_to_S filterExpsParser $
        T.unpack (unwords $ fromMaybe [""] filterExp)
    filterMay = listToMaybe parserResults

  case filterMay of
    Nothing -> do
      tasks <-
        queryNamed
          connection
          [sql|
            SELECT *
            FROM tasks_view
            ORDER BY ulid DESC
            LIMIT :taskCount
          |]
          [ ":taskCount" := case availableLinesMb of
              Nothing -> -1 -- No limit
              Just availableLines -> availableLines
          ]

      formatTasksColor conf now (isJust availableLinesMb) tasks
    --
    Just (filterExps, _) -> do
      let
        ppInvalidFilter = \case
          (InvalidFilter error) ->
            dquotes (pretty error) <+> "is an invalid filter"
          (HasStatus Nothing) -> "Filter contains an invalid state value"
          _ -> "The functions should not be called with a valid function"
        errors = P.filter (not . isValidFilter) filterExps

      if P.length errors > 0
        then
          pure $
            vsep (fmap (annotate (colr conf Red) . ppInvalidFilter) errors)
              <> hardline
              <> hardline
        else do
          tasks <-
            query_
              connection
              (getFilterQuery filterExps (Just "ulid DESC") availableLinesMb)
          formatTasksColor conf now (isJust availableLinesMb) tasks


listOldTasks ::
  Config -> DateTime -> Connection -> Maybe Int -> IO (Doc AnsiStyle)
listOldTasks conf now connection availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        WHERE closed_utc IS NULL
        ORDER BY ulid ASC
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]

  formatTasksColor conf now (isJust availableLinesMb) tasks


openTasks ::
  Config ->
  DateTime ->
  Connection ->
  Maybe [Text] ->
  Maybe Int ->
  IO (Doc AnsiStyle)
openTasks conf now connection filterExpression availableLinesMb = do
  let
    parserResults =
      readP_to_S filterExpsParser $
        T.unpack (unwords $ fromMaybe [""] filterExpression)
    filterMay = listToMaybe parserResults

  case filterMay of
    Nothing -> do
      (tasks :: [FullTask]) <-
        queryNamed
          connection
          [sql|
            SELECT *
            FROM tasks_view
            WHERE closed_utc IS NULL
            ORDER BY priority DESC, due_utc ASC, ulid DESC
            LIMIT :taskCount
          |]
          [ ":taskCount" := case availableLinesMb of
              Nothing -> -1 -- No limit
              Just availableLines -> availableLines
          ]

      formatTasksColor conf now (isJust availableLinesMb) tasks
    --
    Just (filterExps, _) -> do
      let
        ppInvalidFilter = \case
          (InvalidFilter error) ->
            dquotes (pretty error) <+> "is an invalid filter"
          (HasStatus Nothing) -> "Filter contains an invalid state value"
          _ -> "The functions should not be called with a valid function"
        errors = P.filter (not . isValidFilter) filterExps

      if P.length errors > 0
        then
          pure $
            vsep (fmap (annotate (colr conf Red) . ppInvalidFilter) errors)
              <> hardline
              <> hardline
        else do
          let
            isStateExp = \case (HasStatus _) -> True; _ -> False
            filterExpWithOpen =
              if P.any isStateExp filterExps
                then filterExps
                else HasStatus (Just IsOpen) : filterExps
            sqlQuery =
              getFilterQuery
                filterExpWithOpen
                (Just "priority DESC, due_utc ASC, ulid DESC")
                availableLinesMb

          tasks <- query_ connection sqlQuery
          formatTasksColor conf now (isJust availableLinesMb) tasks


modifiedTasks ::
  Config ->
  DateTime ->
  Connection ->
  ListModifiedFlag ->
  Maybe Int ->
  IO (Doc AnsiStyle)
modifiedTasks conf now connection listModifiedFlag availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        ORDER BY modified_utc Desc
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]

  let
    filterModified =
      P.filter
        ( \task ->
            removeNSec (ulidTextToDateTime task.ulid)
              /= parseUtc task.modified_utc
        )

    removeNSec :: Maybe DateTime -> Maybe DateTime
    removeNSec mDateTime =
      case mDateTime of
        Just dateTime ->
          Just $
            dateTime{dtTime = (dtTime dateTime){todNSec = 0}}
        Nothing -> Nothing

    filteredTasks =
      case listModifiedFlag of
        AllItems -> tasks
        ModifiedItemsOnly -> filterModified tasks

  formatTasksColor conf now (isJust availableLinesMb) filteredTasks


overdueTasks ::
  Config -> DateTime -> Connection -> Maybe Int -> IO (Doc AnsiStyle)
overdueTasks conf now connection availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        WHERE
          closed_utc IS NULL AND
          due_utc < datetime('now')
        ORDER BY
          priority DESC,
          due_utc ASC,
          ulid Desc
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]

  formatTasksColor conf now (isJust availableLinesMb) tasks


doneTasks :: Config -> DateTime -> Connection -> Maybe Int -> IO (Doc AnsiStyle)
doneTasks conf now connection availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        WHERE
          closed_utc IS NOT NULL AND
          state == 'Done'
        ORDER BY closed_utc DESC
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]

  formatTasksColor conf now (isJust availableLinesMb) tasks


obsoleteTasks ::
  Config -> DateTime -> Connection -> Maybe Int -> IO (Doc AnsiStyle)
obsoleteTasks conf now connection availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        WHERE
          closed_utc IS NOT NULL AND
          state == 'Obsolete'
        ORDER BY ulid DESC
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]

  formatTasksColor conf now (isJust availableLinesMb) tasks


deletableTasks ::
  Config -> DateTime -> Connection -> Maybe Int -> IO (Doc AnsiStyle)
deletableTasks conf now connection availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        WHERE
          closed_utc IS NOT NULL
          AND state == 'Deletable'
        ORDER BY ulid DESC
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]
  formatTasksColor conf now (isJust availableLinesMb) tasks


listRepeating ::
  Config -> DateTime -> Connection -> Maybe Int -> IO (Doc AnsiStyle)
listRepeating conf now connection availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        WHERE repetition_duration IS NOT NULL
        ORDER BY repetition_duration Desc
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]

  formatTasksColor conf now (isJust availableLinesMb) tasks


listRecurring ::
  Config -> DateTime -> Connection -> Maybe Int -> IO (Doc AnsiStyle)
listRecurring conf now connection availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        WHERE recurrence_duration IS NOT NULL
        ORDER BY recurrence_duration DESC
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]

  formatTasksColor conf now (isJust availableLinesMb) tasks


listReady ::
  Config -> DateTime -> Connection -> Maybe P.Int -> IO (Doc AnsiStyle)
listReady conf now connection availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        WHERE
          (ready_utc IS NULL OR
            (ready_utc IS NOT NULL AND ready_utc < datetime('now'))
          ) AND
          waiting_utc IS NULL AND
          ready_utc IS NULL AND
          closed_utc IS NULL
        ORDER BY
          priority DESC,
          due_utc ASC,
          ulid DESC
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]

  formatTasksColor conf now (isJust availableLinesMb) tasks


listWaiting ::
  Config -> DateTime -> Connection -> Maybe Int -> IO (Doc AnsiStyle)
listWaiting conf now connection availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        WHERE closed_utc IS NULL
          AND waiting_utc IS NOT NULL
          AND (review_utc > datetime('now') OR review_utc IS NULL)
        ORDER BY waiting_utc DESC
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]

  formatTasksColor conf now (isJust availableLinesMb) tasks


listAll :: Config -> DateTime -> Connection -> Maybe Int -> IO (Doc AnsiStyle)
listAll conf now connection availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        ORDER BY ulid ASC
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]

  formatTasksColor conf now (isJust availableLinesMb) tasks


listNoTag :: Config -> DateTime -> Connection -> Maybe Int -> IO (Doc AnsiStyle)
listNoTag conf now connection availableLinesMb = do
  tasks <-
    queryNamed
      connection
      [sql|
        SELECT *
        FROM tasks_view
        WHERE
          closed_utc IS NULL AND
          tags IS NULL
        ORDER BY
          priority DESC,
          due_utc ASC,
          ulid DESC
        LIMIT :taskCount
      |]
      [ ":taskCount" := case availableLinesMb of
          Nothing -> -1 -- No limit
          Just availableLines -> availableLines
      ]

  formatTasksColor conf now (isJust availableLinesMb) tasks


getWithTag ::
  Connection ->
  Maybe DerivedState ->
  Maybe Int ->
  [Text] ->
  IO [FullTask]
getWithTag connection stateMaybe availableLinesMb tags = do
  let
    tagQuery = case tags of
      [] -> ""
      _ ->
        tags
          <&> (\t -> "tag LIKE '" <> t <> "'")
          & T.intercalate " OR "
          & ("AND " <>)

    stateQuery = case stateMaybe of
      Nothing -> ""
      Just derivedState -> "AND " <> derivedStateToQuery derivedState

    -- `WHERE TRUE` simplifies adding additional filters with "AND"
    ulidsQuery =
      "\
      \SELECT tasks.ulid \n\
      \FROM tasks \n\
      \LEFT JOIN task_to_tag ON tasks.ulid IS task_to_tag.task_ulid \n\
      \WHERE TRUE \n\
      \"
        <> tagQuery
        <> " \
           \"
        <> stateQuery
        <> " \
           \GROUP BY tasks.ulid \
           \HAVING count(tag) = "
        <> show (P.length tags)

    mainQuery =
      FullTask.selectQuery
        <> "\
           \FROM ("
        <> Query ulidsQuery
        <> ") tasks1\n\
           \LEFT JOIN tasks_view ON tasks1.ulid IS tasks_view.ulid\n\
           \ORDER BY \n\
           \  priority DESC,\n\
           \  due_utc ASC,\n\
           \  ulid DESC\n\
           \LIMIT "
        <> Query
          ( show @Int $ case availableLinesMb of
              Nothing -> -1 -- No limit
              Just availableLines -> availableLines
          )

  query_ connection mainQuery


listWithTag ::
  Config -> DateTime -> Connection -> [Text] -> Maybe Int -> IO (Doc AnsiStyle)
listWithTag conf now connection tags availableLinesMb = do
  tasks <- getWithTag connection Nothing availableLinesMb tags
  formatTasksColor conf now (isJust availableLinesMb) tasks


queryTasks :: Config -> DateTime -> Connection -> Text -> IO (Doc AnsiStyle)
queryTasks conf now connection sqlQuery = do
  tasks <-
    query_ connection $
      Query $
        "SELECT * FROM tasks_view WHERE " <> sqlQuery
  formatTasksColor conf now False tasks


runSql :: Config -> Text -> IO (Doc AnsiStyle)
runSql conf sqlQuery = do
  result <-
    readProcess
      "sqlite3"
      [ conf.dataDir </> conf.dbName
      , ".headers on"
      , ".mode csv"
      , ".separator , '\n'"
      , T.unpack sqlQuery
      ]
      []
  -- Remove trailing newline
  pure $ pretty (T.dropEnd 1 $ T.pack result)


data FilterExp
  = HasTag Text
  | NotTag Text
  | HasDue Text
  | HasStatus (Maybe DerivedState) -- Should be `Either`
  | InvalidFilter Text
  deriving (Show)


tagParser :: ReadP FilterExp
tagParser = do
  _ <- char '+'
  aTag <- munch (not . isSpace)
  pure $ HasTag $ T.pack aTag


notTagParser :: ReadP FilterExp
notTagParser = do
  _ <- char '-'
  aTag <- munch (not . isSpace)
  pure $ NotTag $ T.pack aTag


dueParser :: ReadP FilterExp
dueParser = do
  _ <- string "due:"
  utcStr <- munch (not . isSpace)
  pure $ HasDue $ T.pack utcStr


stateParser :: ReadP FilterExp
stateParser = do
  _ <- string "state:"
  stateStr <- munch (not . isSpace)
  pure $ HasStatus $ textToDerivedState $ T.pack stateStr


filterExpParser :: ReadP FilterExp
filterExpParser =
  do
    tagParser
    <++ notTagParser
    <++ dueParser
    <++ stateParser
    <++ (InvalidFilter . T.pack <$> munch1 (not . isSpace))


filterExpsParser :: ReadP [FilterExp]
filterExpsParser = do
  val <- sepBy1 filterExpParser skipSpaces
  eof
  return val


parseFilterExps :: Text -> IO ()
parseFilterExps input =
  P.print $ readP_to_S filterExpsParser $ T.unpack input


{-| Returns (operator, where-query) tuple
 TODO: Should be `FilterExp -> Maybe (Text, Text)`
-}
filterToSql :: FilterExp -> (Text, Text)
filterToSql = \case
  HasTag tag -> ("INTERSECT", "tag LIKE " <> quoteText tag)
  NotTag tag -> ("EXCEPT", "tag LIKE " <> quoteText tag)
  HasDue utc -> ("INTERSECT", "due_utc < datetime(" <> quoteText utc <> ")")
  HasStatus (Just taskState) -> ("INTERSECT", derivedStateToQuery taskState)
  -- Following cases should never be called, as they are filtered out
  HasStatus Nothing -> ("", "")
  InvalidFilter _ -> ("", "")


isValidFilter :: FilterExp -> Bool
isValidFilter = \case
  InvalidFilter _ -> False
  HasStatus Nothing -> False
  _ -> True


runFilter ::
  Config -> DateTime -> Connection -> [Text] -> Maybe Int -> IO (Doc AnsiStyle)
runFilter conf now connection exps availableLinesMb = do
  let
    parserResults = readP_to_S filterExpsParser $ T.unpack $ unwords exps
    filterHelp =
      "Filter expressions must be a list of key[:value] entries."
        <> hardline
    dieWithError err = do
      hPutDoc stderr $ annotate (colr conf Red) err
      exitFailure

  case parserResults of
    [(filterExps, _)] -> do
      let
        ppInvalidFilter = \case
          (InvalidFilter error) ->
            dquotes (pretty error)
              <+> "is an invalid filter."
                <> hardline
                <> filterHelp
          (HasStatus Nothing) ->
            "Filter contains an invalid state value"
          _ ->
            "The functions should not be called with a valid function"
        errors = P.filter (not . isValidFilter) filterExps
        sqlQuery = getFilterQuery filterExps Nothing availableLinesMb

      tasks <- query_ connection sqlQuery

      if P.length errors > 0
        then dieWithError $ vsep (fmap ppInvalidFilter errors)
        else
          if P.length tasks <= 0
            then pure "No tasks available for the given filter."
            else formatTasksColor conf now False tasks
    _ -> dieWithError filterHelp


-- TODO: Increase performance of this query
getFilterQuery :: [FilterExp] -> Maybe Text -> Maybe Int -> Query
getFilterQuery filterExps orderByMb availableLinesMb = do
  let
    filterTuple =
      filterToSql <$> P.filter isValidFilter filterExps

    queries =
      filterTuple <&> \(operator, whereQuery) ->
        operator
          <> "\n\
             \SELECT tasks.ulid\n\
             \FROM tasks\n\
             \LEFT JOIN task_to_tag ON tasks.ulid IS task_to_tag.task_ulid\n\
             \WHERE "
          <> whereQuery
          <> "\n\
             \GROUP BY tasks.ulid"

    ulidsQuery =
      "SELECT tasks.ulid FROM tasks\n"
        <> unlines queries

    orderBy = Query $ case orderByMb of
      Nothing ->
        "ORDER BY \n\
        \  priority DESC,\n\
        \  due_utc ASC,\n\
        \  ulid DESC\n"
      Just orderByTxt ->
        "ORDER BY \n" <> orderByTxt <> "\n"

  FullTask.selectQuery
    <> "FROM ("
    <> Query ulidsQuery
    <> ") tasks1\n\
       \LEFT JOIN tasks_view ON tasks1.ulid IS tasks_view.ulid\n"
    <> orderBy
    <> Query
      ( "LIMIT "
          <> ( show @P.Int $ case availableLinesMb of
                Nothing -> -1 -- No limit
                Just availableLines -> availableLines
             )
          <> "\n"
      )


columnToDoc :: Config -> Int -> Column -> Doc AnsiStyle
columnToDoc conf idColWidth = do
  let strong = bold <> underlined

  \case
    IdCol ->
      annotate
        (conf.idStyle <> strong)
        (fill idColWidth "Id")
    PrioCol ->
      annotate
        (conf.priorityStyle <> strong)
        (fill (colToWidth conf idColWidth PrioCol) "Prio")
    OpenedUTCCol ->
      annotate
        (conf.dateStyle <> strong)
        (fill (colToWidth conf idColWidth OpenedUTCCol) "Opened UTC")
    AgeCol ->
      annotate
        (conf.dateStyle <> strong)
        (fill (colToWidth conf idColWidth AgeCol) "Age")
    BodyCol ->
      annotate
        (conf.bodyStyle <> strong)
        (fill (colToWidth conf idColWidth BodyCol) "Body")
    EmptyCol ->
      mempty


formatTasks :: Config -> DateTime -> Bool -> [FullTask] -> Doc AnsiStyle
formatTasks conf now isTruncated tasks =
  if P.length tasks == 0
    then pretty noTasksWarning
    else do
      let
        idColWidth = getIdLength $ fromIntegral $ P.length tasks
        docHeader =
          concatWith (<++>) $
            ( conf.columns
                & P.filter (/= EmptyCol)
                <&> columnToDoc conf idColWidth
            )
              <> [line]

      docHeader
        <> vsep (fmap (formatTaskLine conf now idColWidth) tasks)
        <> line
        <> if isTruncated
          then
            annotate
              (colr conf Yellow)
              ( "This list is truncated. "
                  <> "List all by piping into `cat` or `less`."
              )
          else mempty


formatTasksColor ::
  Config -> DateTime -> Bool -> [FullTask] -> IO (Doc AnsiStyle)
formatTasksColor conf now isTruncated tasks = do
  confNorm <- applyColorMode conf
  pure $ formatTasks confNorm now isTruncated tasks


getProgressBar :: Config -> Integer -> Double -> Doc AnsiStyle
getProgressBar conf maxWidthInChars progress =
  let
    barWidth = floor (progress * fromInteger maxWidthInChars)
    remainingWidth = fromIntegral $ maxWidthInChars - barWidth
  in
    annotate
      (bgColrDull conf Green <> colrDull conf Green)
      (pretty $ P.take (fromIntegral barWidth) $ P.repeat '#')
      <>
      -- (annotate (bgColrDull conf Green) $ fill (fromIntegral barWidth) "" <>
      annotate (bgColrDull conf Black) (fill remainingWidth "")


formatTagLine ::
  Config -> Int -> (Text, Integer, Integer, Double) -> Doc AnsiStyle
formatTagLine conf maxTagLength (tag, open_count, closed_count, progress) =
  let
    barWidth = toInteger $ progressBarWidth conf
    progressPercentage =
      if progress == 0
        then "     "
        else
          pretty
            ( T.justifyRight 3 ' ' $
                T.pack $
                  showFFloat (Just 0) (progress * 100) ""
            )
            <+> "%"
  in
    fill maxTagLength (pretty tag)
      <++> pretty (T.justifyRight (T.length "open") ' ' $ show open_count)
      <++> pretty (T.justifyRight (T.length "closed") ' ' $ show closed_count)
      <++> progressPercentage
      <+> getProgressBar conf barWidth progress


formatTags :: Config -> [(Text, Integer, Integer, Double)] -> Doc AnsiStyle
formatTags conf tagTuples = do
  let
    percWidth = 6 -- Width of e.g. 100 %
    progressWith = conf.progressBarWidth + percWidth
    firstOf4 (a, _, _, _) = a
    maxTagLength =
      tagTuples
        <&> (T.length . firstOf4)
        & P.maximum

  if P.null tagTuples
    then
      annotate
        (colr conf Yellow)
        "⚠️ No tags available"
    else
      annotate (bold <> underlined) (fill maxTagLength "Tag")
        <++> annotate (bold <> underlined) "Open"
        <++> annotate (bold <> underlined) "Closed"
        <++> annotate (bold <> underlined) (fill progressWith "Progress")
        <> line
        <> vsep (fmap (formatTagLine conf maxTagLength) tagTuples)


listTags :: Config -> Connection -> IO (Doc AnsiStyle)
listTags conf connection = do
  tags <- query_ connection $ Query "SELECT * FROM tags"

  pure $ formatTags conf tags


listProjects :: Config -> Connection -> IO (Doc AnsiStyle)
listProjects conf connection = do
  tags <-
    query_
      connection
      [sql|
        SELECT *
        FROM tags
        WHERE
          "open" > 0 AND
          closed > 0
      |]

  pure $
    if P.null tags
      then
        annotate (colr conf Yellow) $
          "⚠️ No projects available yet. "
            <> "Tag some tasks to populate this list."
      else formatTags conf tags


listNotes :: Config -> Connection -> IO (Doc AnsiStyle)
listNotes conf connection = do
  (notes :: [TaskToNote]) <-
    query_
      connection
      [sql|
        SELECT ulid, task_ulid, note
        FROM task_to_note
        ORDER BY ulid DESC
      |]

  let
    taskIdWidth = 7 -- TODO: Use dynamic width
    noteWidth = getIdLength $ fromIntegral $ P.length notes
    docHeader =
      annotate
        (conf.idStyle <> bold <> underlined)
        (fill taskIdWidth "Task ID")
        <++> annotate
          (conf.idStyle <> bold <> underlined)
          (fill noteWidth "ID")
        <++> annotate (conf.dateStyle <> bold <> underlined) "Created UTC"
        <++> annotate (bold <> underlined) "Note"
        <++> line

    showNote note =
      annotate
        conf.idStyle
        (fill 7 $ pretty $ T.takeEnd taskIdWidth note.task_ulid)
        <++> annotate
          conf.idStyle
          (fill noteWidth $ pretty $ T.takeEnd noteWidth note.ulid)
        <++> annotate
          conf.dateStyle
          ( note.ulid
              & ulidTextToDateTime
              <&> timePrint ISO8601_Date
              & pretty
          )
        <++> pretty note.note
        <> line

  pure $ docHeader <> vsep (notes <&> showNote)


getStats :: Config -> Connection -> IO (Doc AnsiStyle)
getStats _ connection = do
  [NumRows numOfTasksTotal] <-
    query_ connection $
      Query "SELECT count(1) FROM tasks"
  [NumRows numOfTasksOpen] <-
    query_ connection $
      Query "SELECT count(1) FROM tasks WHERE closed_utc IS NULL"
  [NumRows numOfTasksClosed] <-
    query_ connection $
      Query "SELECT count(1) FROM tasks WHERE closed_utc IS NOT NULL"
  [NumRows numOfTasksDone] <-
    query_ connection $
      Query "SELECT count(1) FROM tasks WHERE state IS 'Done'"
  [NumRows numOfTasksObsolete] <-
    query_ connection $
      Query "SELECT count(1) FROM tasks WHERE state IS 'Obsolete'"
  [NumRows numOfTasksDeletable] <-
    query_ connection $
      Query "SELECT count(1) FROM tasks WHERE state IS 'Deletable'"

  let
    widthKey = 12
    widthValue = max 5 $ fromIntegral $ numDigits 10 numOfTasksTotal
    formatLine (name :: Text) (numTasks :: Integer) =
      let
        numTotalInt :: Double = fromIntegral numOfTasksTotal
        numTasksInt :: Double = fromIntegral numTasks
        share = T.pack $ showFFloat (Just 3) (numTasksInt / numTotalInt) ""
      in
        fill widthKey (pretty name)
          <++> fill
            widthValue
            (pretty $ T.justifyRight widthValue ' ' $ show numTasks)
          <++> pretty share

  pure $
    annotate (bold <> underlined) (fill widthKey "State")
      <++> annotate (bold <> underlined) (fill widthValue "Value")
      <++> annotate (bold <> underlined) "Share"
      <> line
      <> vsep
        [ formatLine "Any" numOfTasksTotal
        , formatLine "Open" numOfTasksOpen
        , formatLine "Closed" numOfTasksClosed
        , formatLine "└─ Done" numOfTasksDone
        , formatLine "└─ Obsolete" numOfTasksObsolete
        , formatLine "└─ Deletable" numOfTasksDeletable
        ]