Skip to content

Commit 8028190

Browse files
committed
Feat: Add support for recurring tasks
1 parent b6cbfdb commit 8028190

File tree

8 files changed

+227
-64
lines changed

8 files changed

+227
-64
lines changed

docs-source/concepts.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ State\Field |`group_ulid`|`repetition_duration`|`recurrence_duration`
7272
`Recurring` | ✅ | ❌ | ✅
7373
`Frozen` | ✅ | ❌ | ❌
7474

75+
A task is either recurring or repeating,
76+
but can't be both at the same time.
77+
7578

7679
## Priority
7780

stack.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ packages:
99
extra-deps:
1010
- gi-gtk-declarative-0.4.2
1111
- gi-gtk-declarative-app-simple-0.4.1
12+
- iso8601-duration-0.1.1.0
1213
- portable-lines-0.1
1314
- ulid-0.3.0.0
1415
- github: JakeWheat/simple-sql-parser

stack.yaml.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ packages:
1818
sha256: 3e2ca55f6838ad1fb9e54c4b7a0ec91fb78321a77d6cf27cc59d10750b8da39f
1919
original:
2020
hackage: gi-gtk-declarative-app-simple-0.4.1
21+
- completed:
22+
hackage: iso8601-duration-0.1.1.0@sha256:0e9e7531e71b693c20115198186a0280aa3631dc29da226e84e55f0984bec1af,2210
23+
pantry-tree:
24+
size: 569
25+
sha256: 183be7ad7fded29b7dbf94bd57a58755abeab8f1d1d237ac47f95543a84a8ae7
26+
original:
27+
hackage: iso8601-duration-0.1.1.0
2128
- completed:
2229
hackage: portable-lines-0.1@sha256:21c3b905888a4b43f957cd8e8cdf2af00942bb161aa19a6b20db18b661de0510,1552
2330
pantry-tree:

tasklite-core/app/Main.hs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import Data.String (fromString)
1818
import qualified Data.Text as T
1919
import Data.Text.Prettyprint.Doc hiding ((<>))
2020
import Data.Text.Prettyprint.Doc.Render.Terminal
21+
import qualified Data.Time.ISO8601.Duration as ISO8601
2122
import Data.Version (showVersion)
2223
import Data.Yaml (decodeFileEither, prettyPrintParseException)
2324
import GHC.IO.Encoding (setLocaleEncoding, utf8)
@@ -67,6 +68,7 @@ data Command
6768
| TrashTasks [IdText]
6869
| DeleteTasks [IdText]
6970
| RepeatTasks Duration [IdText]
71+
| RecurTasks ISO8601.Duration [IdText]
7072
| BoostTasks [IdText]
7173
| HushTasks [IdText]
7274
-- | Modify [IdText] Text -- DSL for modifying a task
@@ -240,6 +242,12 @@ parseDurationInDays =
240242
. readMaybe
241243

242244

245+
parseDurationString :: [Char] -> Either [Char] ISO8601.Duration
246+
parseDurationString text = text
247+
& fromString
248+
& ISO8601.parseDuration
249+
250+
243251
commandParser :: Config -> Parser Command
244252
commandParser conf =
245253
let
@@ -323,6 +331,13 @@ commandParser conf =
323331
"Repeat a task x days after its due UTC or after it gets closed \
324332
\(whichever occurs later)")
325333

334+
<> command "recur" (toParserInfo (RecurTasks
335+
<$> argument (eitherReader parseDurationString)
336+
(metavar "DURATION" <> help "ISO8601 duration \
337+
\(e.g. P1DT5H for 1 day and 5 hours)")
338+
<*> some (strArgument idsVar))
339+
"Recur a task DURATION after its due UTC")
340+
326341
<> command "duplicate" (toParserInfo
327342
(Duplicate <$> some (strArgument idsVar))
328343
"Duplicates a task (and resets the closed and due UTC fields)")
@@ -863,6 +878,7 @@ executeCLiCommand conf now connection cmd =
863878
TrashTasks ids -> trashTasks conf connection ids
864879
DeleteTasks ids -> deleteTasks conf connection ids
865880
RepeatTasks duration ids -> repeatTasks conf connection duration ids
881+
RecurTasks duration ids -> recurTasks conf connection duration ids
866882
BoostTasks ids -> adjustPriority conf 1 ids
867883
HushTasks ids -> adjustPriority conf (-1) ids
868884
Start ids -> startTasks conf connection ids

tasklite-core/package.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dependencies:
3535
- hourglass
3636
- hsemail
3737
- huzzy
38+
- iso8601-duration
3839
- optparse-applicative
3940
- parsec
4041
- portable-lines

tasklite-core/source/ImportExport.hs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ import qualified Data.Text as T
1717
import qualified Data.Text.Lazy.Encoding as TL
1818
import Data.Text.Prettyprint.Doc hiding ((<>))
1919
import Data.Text.Prettyprint.Doc.Render.Terminal
20-
import Data.Time.LocalTime (zonedTimeToUTC)
21-
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
2220
import Data.ULID
2321
import qualified Data.Vector as V
2422
import Data.Yaml as Yaml
@@ -331,13 +329,7 @@ emailToImportTask email@(Message headerFields msgBody) =
331329
case headerValue of
332330
Email.Date emailDate ->
333331
let
334-
utc = emailDate
335-
& zonedTimeToUTC
336-
& utcTimeToPOSIXSeconds
337-
& toRational
338-
& rationalToElapsedP
339-
& timeFromElapsedP
340-
:: DateTime
332+
utc = zonedTimeToDateTime emailDate
341333
Right ulidGenerated =
342334
(ulidFromInteger . abs . toInteger . hash) $ (show email :: Text)
343335
ulidCombined = setDateTime ulidGenerated utc

tasklite-core/source/Lib.hs

Lines changed: 162 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Protolude as P
88

99
import Data.Hourglass
1010
import Data.Text as T
11+
import qualified Data.Time.ISO8601.Duration as Iso8601
1112
import Data.ULID
1213
import Data.Coerce
1314
import Data.Yaml as Yaml
@@ -421,6 +422,133 @@ parseIsoDuration isoDuration =
421422
else Nothing
422423

423424

425+
createNextRepetition
426+
:: Config -> Connection -> Task -> IO (Maybe (Doc ann))
427+
createNextRepetition conf connection task = do
428+
newUlidText <- formatUlid getULID
429+
let
430+
taskUlid = primaryKey task
431+
nowMaybe = ulidTextToDateTime newUlidText
432+
dueUtcMb = (Task.due_utc task) >>= parseUtc
433+
showDateTime = pack . timePrint (utcFormat conf)
434+
repIsoDur = (Task.repetition_duration task) >>= parseIsoDuration
435+
nextDueMb = liftA2 timeAdd
436+
(if nowMaybe < dueUtcMb then dueUtcMb else nowMaybe)
437+
repIsoDur
438+
439+
-- TODO: Investigate why this isn't working and replace afterwards
440+
-- runBeamSqlite connection $ runInsert $
441+
-- insert (_tldbTasks taskLiteDb) $
442+
-- insertValues [ task
443+
-- { Task.ulid = val_ newUlidText
444+
-- , Task.due_utc = nowMaybe + (Task.repetition_duration task)
445+
-- }
446+
-- ]
447+
448+
runBeamSqlite connection $ do
449+
runInsert $ insert (_tldbTasks taskLiteDb) $ insertFrom $ do
450+
originalTask <- filter_
451+
(\theTask -> primaryKey theTask ==. val_ taskUlid)
452+
(all_ $ _tldbTasks taskLiteDb)
453+
454+
pure originalTask
455+
{ Task.ulid = val_ newUlidText
456+
, Task.due_utc = val_ $ fmap showDateTime nextDueMb
457+
, Task.awake_utc = val_ $
458+
fmap showDateTime $ liftA2 timeAdd
459+
((Task.awake_utc task) >>= parseUtc)
460+
repIsoDur
461+
, Task.ready_utc = val_ $
462+
fmap showDateTime $ liftA2 timeAdd
463+
((Task.ready_utc task) >>= parseUtc)
464+
repIsoDur
465+
}
466+
467+
-- Duplicate tags
468+
tags <- runSelectReturningList $ select $
469+
filter_ (\tag -> TaskToTag.task_ulid tag ==. val_ taskUlid) $
470+
all_ (_tldbTaskToTag taskLiteDb)
471+
472+
liftIO $ insertTags
473+
connection
474+
Nothing
475+
(TaskUlid newUlidText)
476+
(fmap TaskToTag.tag tags)
477+
478+
liftIO $ pure $ Just $ "➡️ Created next task"
479+
<+> dquotes (pretty $ Task.body task)
480+
<+> "in repetition series" <+> dquotes (pretty $ Task.group_ulid task)
481+
<+> "with id" <+> dquotes (pretty newUlidText)
482+
483+
484+
createNextRecurrence
485+
:: Config -> Connection -> Task -> IO (Maybe (Doc ann))
486+
createNextRecurrence conf connection task = do
487+
newUlidText <- formatUlid getULID
488+
let
489+
taskUlid = primaryKey task
490+
dueUtcMb = (Task.due_utc task) >>= parseUtc
491+
492+
showDateTime :: DateTime -> Text
493+
showDateTime = pack . timePrint (utcFormat conf)
494+
495+
durTextEither = maybeToEither
496+
"Task has no recurrence duration"
497+
(Task.recurrence_duration task)
498+
isoDurEither =
499+
durTextEither
500+
<&> encodeUtf8
501+
>>= Iso8601.parseDuration
502+
503+
showEither e = e
504+
& (either (const Nothing) Just)
505+
<&> utcTimeToDateTime
506+
<&> showDateTime
507+
508+
nextDueMb = liftA2 Iso8601.addDuration isoDurEither
509+
(maybeToEither "Task has no due UTC" (dueUtcMb <&> dateTimeToUtcTime))
510+
511+
runBeamSqlite connection $ do
512+
runInsert $ insert (_tldbTasks taskLiteDb) $ insertFrom $ do
513+
originalTask <- filter_
514+
(\theTask -> primaryKey theTask ==. val_ taskUlid)
515+
(all_ $ _tldbTasks taskLiteDb)
516+
517+
pure originalTask
518+
{ Task.ulid = val_ newUlidText
519+
, Task.due_utc = val_ $ nextDueMb & showEither
520+
521+
, Task.awake_utc = val_ $
522+
(liftA2 Iso8601.addDuration isoDurEither
523+
(maybeToEither "Task has no awake UTC"
524+
((Task.awake_utc task) >>= parseUtc <&> dateTimeToUtcTime)))
525+
& showEither
526+
527+
, Task.ready_utc = val_ $
528+
(liftA2 Iso8601.addDuration isoDurEither
529+
(maybeToEither "Task has no ready UTC"
530+
((Task.ready_utc task) >>= parseUtc <&> dateTimeToUtcTime)))
531+
& showEither
532+
533+
}
534+
535+
-- Duplicate tags
536+
tags <- runSelectReturningList $ select $
537+
filter_ (\tag -> TaskToTag.task_ulid tag ==. val_ taskUlid) $
538+
all_ (_tldbTaskToTag taskLiteDb)
539+
540+
liftIO $ insertTags
541+
connection
542+
Nothing
543+
(TaskUlid newUlidText)
544+
(fmap TaskToTag.tag tags)
545+
546+
liftIO $ pure $ Just $ "➡️ Created next task"
547+
<+> dquotes (pretty $ Task.body task)
548+
<+> "in recurrence series" <+> dquotes (pretty $ Task.group_ulid task)
549+
<+> "with id" <+> dquotes (pretty newUlidText)
550+
551+
424552
doTasks :: Config -> Connection -> Maybe [Text] -> [Text] -> IO (Doc AnsiStyle)
425553
doTasks conf connection noteWordsMaybe ids = do
426554
docs <- forM ids $ \idSubstr -> do
@@ -432,61 +560,12 @@ doTasks conf connection noteWordsMaybe ids = do
432560
then pure $ "⚠️ Task" <+> dquotes (pretty idText) <+> "is already done"
433561
else do
434562
logMessageMaybe <-
435-
if Task.repetition_duration task == Nothing
436-
then pure Nothing
437-
else do
438-
newUlid <- formatUlid getULID
439-
let
440-
nowMaybe = ulidTextToDateTime newUlid
441-
dueUtc = (Task.due_utc task) >>= parseUtc
442-
showDateTime = pack . timePrint (utcFormat conf)
443-
nextDue = liftA2 timeAdd
444-
(if nowMaybe < dueUtc then dueUtc else nowMaybe)
445-
((Task.repetition_duration task) >>= parseIsoDuration)
446-
447-
-- TODO: Investigate why this isn't working and replace afterwards
448-
-- runBeamSqlite connection $ runInsert $
449-
-- insert (_tldbTasks taskLiteDb) $
450-
-- insertValues [ task
451-
-- { Task.ulid = val_ newUlid
452-
-- , Task.due_utc = nowMaybe + (Task.repetition_duration task)
453-
-- }
454-
-- ]
455-
456-
runBeamSqlite connection $ do
457-
runInsert $ insert (_tldbTasks taskLiteDb) $ insertFrom $ do
458-
originalTask <- filter_
459-
(\theTask -> primaryKey theTask ==. val_ taskUlid)
460-
(all_ $ _tldbTasks taskLiteDb)
461-
462-
pure originalTask
463-
{ Task.ulid = val_ newUlid
464-
, Task.due_utc = val_ $ fmap showDateTime nextDue
465-
, Task.awake_utc = val_ $
466-
fmap showDateTime $ liftA2 timeAdd
467-
((Task.awake_utc task) >>= parseUtc)
468-
((Task.repetition_duration task) >>= parseIsoDuration)
469-
, Task.ready_utc = val_ $
470-
fmap showDateTime $ liftA2 timeAdd
471-
((Task.ready_utc task) >>= parseUtc)
472-
((Task.repetition_duration task) >>= parseIsoDuration)
473-
}
474-
475-
-- Duplicate tags
476-
tags <- runSelectReturningList $ select $
477-
filter_ (\tag -> TaskToTag.task_ulid tag ==. val_ taskUlid) $
478-
all_ (_tldbTaskToTag taskLiteDb)
479-
480-
liftIO $ insertTags
481-
connection
482-
Nothing
483-
(TaskUlid newUlid)
484-
(fmap TaskToTag.tag tags)
485-
486-
liftIO $ pure $ Just $ "➡️ Created next task"
487-
<+> dquotes (pretty $ Task.body task)
488-
<+> "in repetition series"
489-
<+> dquotes (pretty $ Task.group_ulid task)
563+
if Task.repetition_duration task /= Nothing
564+
then createNextRepetition conf connection task
565+
else
566+
if Task.recurrence_duration task /= Nothing
567+
then createNextRecurrence conf connection task
568+
else pure Nothing
490569

491570
noteMessageMaybe <- case noteWordsMaybe of
492571
Nothing -> pure Nothing
@@ -610,6 +689,34 @@ repeatTasks conf connection duration ids = do
610689
pure $ vsep docs
611690

612691

692+
recurTasks ::
693+
Config -> Connection -> Iso8601.Duration -> [IdText] -> IO (Doc AnsiStyle)
694+
recurTasks conf connection duration ids = do
695+
docs <- forM ids $ \idSubstr ->
696+
execWithTask conf connection idSubstr $ \task -> do
697+
let
698+
taskUlid@(TaskUlid idText) = primaryKey task
699+
prettyBody = dquotes (pretty $ Task.body task)
700+
prettyId = dquotes (pretty idText)
701+
durationIsoText = decodeUtf8 $ Iso8601.formatDuration duration
702+
703+
groupUlid <- formatUlid getULID
704+
705+
runBeamSqlite connection $ runUpdate $
706+
update (_tldbTasks taskLiteDb)
707+
(\task_ -> mconcat
708+
[ (Task.recurrence_duration task_) <-. val_ (Just durationIsoText)
709+
, (Task.group_ulid task_) <-. val_ (Just groupUlid)
710+
])
711+
(\task_ -> primaryKey task_ ==. val_ taskUlid)
712+
713+
pure $ "📅 Set recurrence duration of task" <+> prettyBody
714+
<+> "with id" <+> prettyId
715+
<+> "to" <+> dquotes (pretty $ durationIsoText)
716+
717+
pure $ vsep docs
718+
719+
613720
adjustPriority :: Config -> Float -> [IdText] -> IO (Doc AnsiStyle)
614721
adjustPriority conf adjustment ids = do
615722
dbPath <- getDbPath conf

0 commit comments

Comments
 (0)