From de22e5ac5b9c3833b9b3bbed05d3800bf485c103 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Thu, 16 Oct 2025 17:20:08 +0200 Subject: [PATCH 01/18] Prepare t5 executable --- README.md | 4 ++++ t4-tui/TUI.hs | 4 ++++ terminal-time-tracking-tool.cabal | 6 ++++++ 3 files changed, 14 insertions(+) create mode 100644 t4-tui/TUI.hs diff --git a/README.md b/README.md index 7656d3e..d9ec399 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ Haskell library and terminal GUI tool for time tracking. cabal run t4 +## Run t4 TUI + + cabal run t5 + ## Run tests cabal test --test-show-details=direct diff --git a/t4-tui/TUI.hs b/t4-tui/TUI.hs new file mode 100644 index 0000000..f2a01e2 --- /dev/null +++ b/t4-tui/TUI.hs @@ -0,0 +1,4 @@ +module Main where + +main :: IO () +main = putStrLn "t4 TUI" diff --git a/terminal-time-tracking-tool.cabal b/terminal-time-tracking-tool.cabal index 3bcc1ba..b5eab70 100644 --- a/terminal-time-tracking-tool.cabal +++ b/terminal-time-tracking-tool.cabal @@ -51,6 +51,12 @@ executable t4 hs-source-dirs: t4-commands main-is: Commands.hs +executable t5 + import: basics + build-depends: terminal-time-tracking-tool + hs-source-dirs: t4-tui + main-is: TUI.hs + test-suite t4-test import: basics ghc-options: -Wno-orphans From e20ef1c7e52a06da5ec2c8104d4c727e145d7153 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Wed, 22 Oct 2025 11:01:04 +0200 Subject: [PATCH 02/18] Add case-insensitive completion unit test (and fix typo) --- test/CompletionSpec.hs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/CompletionSpec.hs b/test/CompletionSpec.hs index ad0bbc7..8bf6ce2 100644 --- a/test/CompletionSpec.hs +++ b/test/CompletionSpec.hs @@ -19,6 +19,8 @@ spec = do complMatch "bar" "foo bar baz" `shouldBe` True it "Not a substring" $ complMatch "quux" "foo bar baz" `shouldBe` False + it "Case-insensitive" $ + complMatch "bAr" "foo BaR baz" `shouldBe` True it "Reversed" $ complMatch "bar" "foo rab baz" `shouldBe` False it "Subsequence with holes" $ @@ -60,7 +62,7 @@ spec = do forAll (elements suggestions) $ \sugg -> complMatch str sugg `shouldBe` True - prop "Not-suggestions match not" $ \aitems -> + prop "Not-suggestions don't match" $ \aitems -> forAll (genShortSublists $ concat aitems) $ \str -> let nopes = aitems \\ complete (Compl aitems id) str in not (null nopes) ==> From 3f03471d4950ac39e25a570fae1c5a92b27f13d7 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Wed, 22 Oct 2025 11:02:25 +0200 Subject: [PATCH 03/18] Rename test suite (as t4/t5 are executables) --- terminal-time-tracking-tool.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/terminal-time-tracking-tool.cabal b/terminal-time-tracking-tool.cabal index b5eab70..efa832e 100644 --- a/terminal-time-tracking-tool.cabal +++ b/terminal-time-tracking-tool.cabal @@ -57,7 +57,7 @@ executable t5 hs-source-dirs: t4-tui main-is: TUI.hs -test-suite t4-test +test-suite library-test import: basics ghc-options: -Wno-orphans type: exitcode-stdio-1.0 From 370bf17a5d93caf2285edd7eab0df286ef6f0ae9 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Thu, 23 Oct 2025 09:21:05 +0200 Subject: [PATCH 04/18] Add haskeline completion integration --- lib/Completion.hs | 9 +++++ t4-tui/TUI.hs | 10 ++++- terminal-time-tracking-tool.cabal | 2 +- test/CompletionSpec.hs | 66 ++++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/lib/Completion.hs b/lib/Completion.hs index 4f14549..6a7a915 100644 --- a/lib/Completion.hs +++ b/lib/Completion.hs @@ -3,6 +3,7 @@ module Completion where import Data.Char import Data.List import Data.Function +import qualified System.Console.Haskeline.Completion as HC data Completion a = Compl { complItems :: [a] @@ -15,3 +16,11 @@ complMatch cs = (isSubsequenceOf `on` map toLower) cs complete :: Completion a -> String -> [a] complete (Compl xs toStr) cs = filter (complMatch cs . toStr) xs + +haskelineCompletions :: Completion a -> String -> [HC.Completion] +haskelineCompletions c@(Compl _ toString) = + map (HC.simpleCompletion . toString) . complete c + +haskelineCompletionFunc :: Monad m => Completion a -> HC.CompletionFunc m +haskelineCompletionFunc compl (x, _) = + return ("", haskelineCompletions compl (reverse x)) diff --git a/t4-tui/TUI.hs b/t4-tui/TUI.hs index f2a01e2..c797e7b 100644 --- a/t4-tui/TUI.hs +++ b/t4-tui/TUI.hs @@ -1,4 +1,10 @@ -module Main where +import Completion +import qualified System.Console.Haskeline as H main :: IO () -main = putStrLn "t4 TUI" +main = H.runInputTBehavior H.preferTerm (H.defaultSettings :: H.Settings IO) { H.complete = haskelineCompletionFunc fooc } $ do + minput <- H.getInputLine "% " + case minput of + Nothing -> return () + Just input -> H.outputStrLn $ "Input was: " ++ input + where fooc = Compl ["foo", "bar", "baz"] id diff --git a/terminal-time-tracking-tool.cabal b/terminal-time-tracking-tool.cabal index efa832e..924171d 100644 --- a/terminal-time-tracking-tool.cabal +++ b/terminal-time-tracking-tool.cabal @@ -32,7 +32,7 @@ common basics , yaml , regex-tdfa , text - , brick + , haskeline library import: basics diff --git a/test/CompletionSpec.hs b/test/CompletionSpec.hs index 8bf6ce2..2239fa9 100644 --- a/test/CompletionSpec.hs +++ b/test/CompletionSpec.hs @@ -6,8 +6,10 @@ import Test.QuickCheck import Completion import Data.Char +import Data.String () import Data.List import Data.Functor.Identity +import qualified System.Console.Haskeline.Completion as HC spec :: Spec spec = do @@ -52,7 +54,7 @@ spec = do complete compl "f bar" `shouldBe` [Identity "foo bar"] it "'f barz' -> 0/3" $ complete compl "f barz" `shouldBe` [] - + describe "Arbitrary completion" $ do prop "Suggestions match" $ \aitems -> @@ -69,10 +71,47 @@ spec = do forAll (elements nopes) $ \nope -> complMatch str nope `shouldBe` False + context "Haskeline completion" $ do + + describe "Completion list generation" $ do + + it "Basic transformation" $ + haskelineCompletions (Compl ["foo"] id) "fo" + `shouldBe` [HC.Completion "foo" "foo" True] + + prop "Replacement = Display" $ + forAll genMatchPairs $ \(compl, match) -> + forAll (elements $ haskelineCompletions compl match) $ \hc -> + HC.replacement hc `shouldBe` HC.display hc + + prop "Result is finished" $ + forAll genMatchPairs $ \(compl, match) -> + forAll (elements $ haskelineCompletions compl match) + HC.isFinished + + prop "Input matches display" $ + forAll genMatchPairs $ \(compl, match) -> + forAll (elements $ haskelineCompletions compl match) $ \hc -> + HC.display hc `shouldSatisfy` complMatch match + + prop "Same completion list" $ + forAll genMatchPairs $ \(compl, match) -> + map HC.display (haskelineCompletions compl match) + `shouldBe` complete compl match + + describe "Completion function transformation" $ do + + prop "Completion function call with id monad" $ + forAll genMatchPairs $ \(compl, match) -> + let complf = haskelineCompletionFunc compl + result = complf (reverse match, "") + compls = haskelineCompletions compl match + in runIdentity result `shouldBe` ("", compls) + subSeqPairs :: Arbitrary a => Gen ([a], [a]) subSeqPairs = do xs <- arbitrary `suchThat` (not . all null) - ys <- scale (*2) $ arbitrary + ys <- scale (*2) arbitrary return (concat xs, concat (il ys xs)) where il [] xs = xs il ys [] = ys @@ -82,3 +121,26 @@ genShortSublists :: (Arbitrary a, Eq a) => [a] -> Gen [a] genShortSublists xs = do len <- choose (1,2) vectorOf len (elements $ nub xs) + +genCompletions :: Gen (Completion String) +genCompletions = Compl <$> arbitrary <*> return id + +notEmpty :: [a] -> Bool +notEmpty = not . null +noEmpty :: [[a]] -> Bool +noEmpty = (&&) <$> notEmpty <*> all notEmpty + +genMatches :: Completion a -> Gen String +genMatches (Compl ws toString) = do + str <- (toString <$> elements ws) `suchThat` notEmpty + sublistOf str `suchThat` notEmpty + +genMatchPairs :: Gen (Completion String, String) +genMatchPairs = do + compl <- genCompletions `suchThat` (noEmpty . complItems) + match <- genMatches compl + return (compl, match) + +instance Show (Completion a) where + show (Compl items toString) = + "Compl (complItems=" ++ show (toString <$> items) ++ ")" From 7ab1bc03b35cfd72e23994f1b60fdfc4324f4b44 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Thu, 23 Oct 2025 11:37:55 +0200 Subject: [PATCH 05/18] Add safe last function to Util --- lib/Util.hs | 4 ++++ test/UtilSpec.hs | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/lib/Util.hs b/lib/Util.hs index 0b56220..3c4bcf7 100644 --- a/lib/Util.hs +++ b/lib/Util.hs @@ -69,3 +69,7 @@ showRoughDiffTime dc = showDiffTimeSplits . init . splitDiffTime dc getCurrentSLT :: IO SimpleLocalTime getCurrentSLT = SLT . zonedTimeToLocalTime <$> getZonedTime + +lastMaybe :: [a] -> Maybe a +lastMaybe [] = Nothing +lastMaybe xs = Just (last xs) diff --git a/test/UtilSpec.hs b/test/UtilSpec.hs index dd04e1c..722adb2 100644 --- a/test/UtilSpec.hs +++ b/test/UtilSpec.hs @@ -136,6 +136,16 @@ spec = do swords = map (\(i,s) -> show i ++ short s) splits showRoughDiffTime dc d `shouldBe` unwords swords + context "Safe list operations" $ do + + describe "last" $ do + it "Empty list -> Nothing" $ + lastMaybe ([] :: [Int]) `shouldBe` Nothing + prop "Singleton -> that element" $ \i -> + lastMaybe [i] `shouldBe` Just (i :: Int) + prop "Non-empty list -> compatible with last" $ \xs -> + not (null xs) ==> + lastMaybe xs `shouldBe` Just (last (xs :: [Int])) instance Arbitrary DurationUnit where arbitrary = DurUnit <$> smol arbitrary From 69b38e5b1077da8e9a72efa1b7ba4732f8e7ce3e Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Thu, 23 Oct 2025 18:11:47 +0200 Subject: [PATCH 06/18] Add simple interactive terminal user interface --- t4-tui/TUI.hs | 58 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/t4-tui/TUI.hs b/t4-tui/TUI.hs index c797e7b..d070301 100644 --- a/t4-tui/TUI.hs +++ b/t4-tui/TUI.hs @@ -1,10 +1,56 @@ +import T4.Data +import T4.Storage +import Util import Completion +import Data.Maybe import qualified System.Console.Haskeline as H main :: IO () -main = H.runInputTBehavior H.preferTerm (H.defaultSettings :: H.Settings IO) { H.complete = haskelineCompletionFunc fooc } $ do - minput <- H.getInputLine "% " - case minput of - Nothing -> return () - Just input -> H.outputStrLn $ "Input was: " ++ input - where fooc = Compl ["foo", "bar", "baz"] id +main = do + sdir <- getStorageDirectory + clock <- lastMaybe <$> loadDataFromDir sdir + showState clock + newClock <- if isJust clock && isIn (fromJust clock) + then promptIn + else promptOut + case newClock of + Nothing -> showState clock + Just c -> do addClockToDir sdir c + showState (Just c) + +showState :: Maybe Clock -> IO () +showState = putStrLn . maybe "No clock data yet" summary + +promptIn :: IO (Maybe Clock) +promptIn = do + choice <- run $ H.getInputChar "[o]ut - [c]hange - [q]uit: " + case choice of + Just 'o' -> Just . Out <$> getCurrentSLT + Just 'c' -> Just <$> clockIn + _ -> return Nothing + +promptOut :: IO (Maybe Clock) +promptOut = do + choice <- run $ H.getInputChar "[i]n - [q]uit: " + case choice of + Just 'i' -> Just <$> clockIn + _ -> return Nothing + +clockIn :: IO Clock +clockIn = do + clocks <- loadDataFromDir =<< getStorageDirectory + let catsCompl = (`Compl` id) $ allCategories clocks + tagsCompl = (`Compl` id) $ allTags clocks + now <- getCurrentSLT + mc <- runWithCompletion catsCompl $ H.getInputLine "Category: " + mtags <- runWithCompletion tagsCompl $ H.getInputLine "Tags: " + return $ In now mc (parseTags mtags) + where parseTags = map (dropWhile (== '#')) . words . fromMaybe [] + +run :: H.InputT IO a -> IO a +run = H.runInputTBehavior H.preferTerm H.defaultSettings + +runWithCompletion :: Completion c -> H.InputT IO a -> IO a +runWithCompletion compl = H.runInputTBehavior H.preferTerm settings + where settings = (H.defaultSettings :: H.Settings IO) {H.complete = hcompl} + hcompl = haskelineCompletionFunc compl From 8e67ef8306d6731b8433052888ccb27d630a8695 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Thu, 23 Oct 2025 19:27:01 +0200 Subject: [PATCH 07/18] Let empty completion substrings match all items --- lib/Completion.hs | 3 +-- test/CompletionSpec.hs | 7 ++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/Completion.hs b/lib/Completion.hs index 6a7a915..735e9da 100644 --- a/lib/Completion.hs +++ b/lib/Completion.hs @@ -11,8 +11,7 @@ data Completion a = Compl } complMatch :: String -> String -> Bool -complMatch "" = const False -complMatch cs = (isSubsequenceOf `on` map toLower) cs +complMatch = isSubsequenceOf `on` map toLower complete :: Completion a -> String -> [a] complete (Compl xs toStr) cs = filter (complMatch cs . toStr) xs diff --git a/test/CompletionSpec.hs b/test/CompletionSpec.hs index 2239fa9..6a14026 100644 --- a/test/CompletionSpec.hs +++ b/test/CompletionSpec.hs @@ -30,9 +30,6 @@ spec = do it "Correct chars, not enough" $ complMatch "foo" "a f b o c d" `shouldBe` False - prop "Empty strings never match" $ \str -> - complMatch "" str `shouldBe` False - prop "Subsequences match" $ forAll subSeqPairs $ \(sml, lrg) -> complMatch sml lrg `shouldBe` True @@ -44,8 +41,8 @@ spec = do let items = ["foo bar", "foo baz", "qoux"] compl = Compl (Identity <$> items) runIdentity - it "Empty -> no suggestion" $ - complete compl "" `shouldBe` [] + it "Empty -> all suggestions" $ + complete compl "" `shouldBe` (Identity <$> items) it "'o' -> 3/3" $ complete compl "o" `shouldBe` (Identity <$> items) it "'f ba' -> 2/3" $ From 38c477e8f42140bdd5e1444229a0ec82b86f51cc Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Thu, 23 Oct 2025 19:29:55 +0200 Subject: [PATCH 08/18] Show spent time on startup and clock out --- t4-tui/TUI.hs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/t4-tui/TUI.hs b/t4-tui/TUI.hs index d070301..3be09aa 100644 --- a/t4-tui/TUI.hs +++ b/t4-tui/TUI.hs @@ -3,6 +3,8 @@ import T4.Storage import Util import Completion import Data.Maybe +import Data.Function +import Data.Time import qualified System.Console.Haskeline as H main :: IO () @@ -11,7 +13,7 @@ main = do clock <- lastMaybe <$> loadDataFromDir sdir showState clock newClock <- if isJust clock && isIn (fromJust clock) - then promptIn + then promptIn (time $ fromJust clock) else promptOut case newClock of Nothing -> showState clock @@ -21,14 +23,22 @@ main = do showState :: Maybe Clock -> IO () showState = putStrLn . maybe "No clock data yet" summary -promptIn :: IO (Maybe Clock) -promptIn = do +promptIn :: SimpleLocalTime -> IO (Maybe Clock) +promptIn started = do + showSpent started choice <- run $ H.getInputChar "[o]ut - [c]hange - [q]uit: " case choice of - Just 'o' -> Just . Out <$> getCurrentSLT + Just 'o' -> showSpent started >> Just . Out <$> getCurrentSLT Just 'c' -> Just <$> clockIn _ -> return Nothing +showSpent :: SimpleLocalTime -> IO () +showSpent started = do + now <- getCurrentSLT + putStrLn $ "Spent: " ++ showDuration (now `minusTime` started) + where showDuration = showRoughDiffTime naturalDurationConfig + minusTime = diffLocalTime `on` getLocalTime + promptOut :: IO (Maybe Clock) promptOut = do choice <- run $ H.getInputChar "[i]n - [q]uit: " From 4da893ccb6bb2711a7de8e2342c0927bc97fdd21 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Thu, 23 Oct 2025 19:30:23 +0200 Subject: [PATCH 09/18] Fix a minor category completion whitespace stripping bug --- t4-tui/TUI.hs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/t4-tui/TUI.hs b/t4-tui/TUI.hs index 3be09aa..cdca9e7 100644 --- a/t4-tui/TUI.hs +++ b/t4-tui/TUI.hs @@ -2,6 +2,8 @@ import T4.Data import T4.Storage import Util import Completion +import Data.Char +import Data.List import Data.Maybe import Data.Function import Data.Time @@ -54,8 +56,9 @@ clockIn = do now <- getCurrentSLT mc <- runWithCompletion catsCompl $ H.getInputLine "Category: " mtags <- runWithCompletion tagsCompl $ H.getInputLine "Tags: " - return $ In now mc (parseTags mtags) - where parseTags = map (dropWhile (== '#')) . words . fromMaybe [] + return $ In now (parseCat mc) (parseTags mtags) + where parseCat = fmap $ dropWhile isSpace . dropWhileEnd isSpace + parseTags = map (dropWhile (== '#')) . words . fromMaybe [] run :: H.InputT IO a -> IO a run = H.runInputTBehavior H.preferTerm H.defaultSettings From a8c71ecdfff5007f3a8fa83383cfaa15b33feb55 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Thu, 23 Oct 2025 20:00:06 +0200 Subject: [PATCH 10/18] Use Haskelines word completion to auto-complete multiple tags --- lib/Completion.hs | 4 ++-- test/CompletionSpec.hs | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/Completion.hs b/lib/Completion.hs index 735e9da..27a8b49 100644 --- a/lib/Completion.hs +++ b/lib/Completion.hs @@ -21,5 +21,5 @@ haskelineCompletions c@(Compl _ toString) = map (HC.simpleCompletion . toString) . complete c haskelineCompletionFunc :: Monad m => Completion a -> HC.CompletionFunc m -haskelineCompletionFunc compl (x, _) = - return ("", haskelineCompletions compl (reverse x)) +haskelineCompletionFunc = + HC.completeWord Nothing " " . (return .) . haskelineCompletions diff --git a/test/CompletionSpec.hs b/test/CompletionSpec.hs index 6a14026..43fc676 100644 --- a/test/CompletionSpec.hs +++ b/test/CompletionSpec.hs @@ -98,13 +98,22 @@ spec = do describe "Completion function transformation" $ do - prop "Completion function call with id monad" $ + prop "Simple arbitrary completion" $ forAll genMatchPairs $ \(compl, match) -> let complf = haskelineCompletionFunc compl result = complf (reverse match, "") compls = haskelineCompletions compl match in runIdentity result `shouldBe` ("", compls) + describe "Examples with word completion" $ do + let compl = Compl (words "foo bar baz") id + complf = haskelineCompletionFunc compl + hcompl w = HC.Completion w w True + it "First word" $ runIdentity (complf ("f", "")) + `shouldBe` ("", [hcompl "foo"]) + it "Second word" $ runIdentity (complf ("ab oof", "")) + `shouldBe` (" oof", hcompl <$> ["bar", "baz"]) + subSeqPairs :: Arbitrary a => Gen ([a], [a]) subSeqPairs = do xs <- arbitrary `suchThat` (not . all null) @@ -120,7 +129,9 @@ genShortSublists xs = do vectorOf len (elements $ nub xs) genCompletions :: Gen (Completion String) -genCompletions = Compl <$> arbitrary <*> return id +genCompletions = do + ws <- listOf $ listOf $ arbitrary `suchThat` (not.isSpace) + return $ Compl ws id notEmpty :: [a] -> Bool notEmpty = not . null From c57bc8dd6ddebe29fd2c559a9ee72dfeab031b79 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Fri, 24 Oct 2025 10:38:18 +0200 Subject: [PATCH 11/18] Include spent seconds to t5 --- t4-tui/TUI.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t4-tui/TUI.hs b/t4-tui/TUI.hs index cdca9e7..13b160a 100644 --- a/t4-tui/TUI.hs +++ b/t4-tui/TUI.hs @@ -38,7 +38,7 @@ showSpent :: SimpleLocalTime -> IO () showSpent started = do now <- getCurrentSLT putStrLn $ "Spent: " ++ showDuration (now `minusTime` started) - where showDuration = showRoughDiffTime naturalDurationConfig + where showDuration = showDiffTime naturalDurationConfig minusTime = diffLocalTime `on` getLocalTime promptOut :: IO (Maybe Clock) From 9851e5f3b42c69fae3a0489ee0c213a7832b5ba8 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Fri, 24 Oct 2025 16:42:33 +0200 Subject: [PATCH 12/18] Move duration map text reports to library --- lib/T4/Report.hs | 13 ++++++++++++- t4-commands/Commands.hs | 25 +++++++++---------------- test/T4/ReportSpec.hs | 27 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/lib/T4/Report.hs b/lib/T4/Report.hs index 9e90dae..00058c5 100644 --- a/lib/T4/Report.hs +++ b/lib/T4/Report.hs @@ -2,7 +2,8 @@ module T4.Report where import T4.Data import Util -import Data.Map (Map) +import Data.List +import Data.Map import Data.Time categoryDurations :: [Clock] -> Map Category NominalDiffTime @@ -14,3 +15,13 @@ tagDurations :: [Clock] -> Map Tag NominalDiffTime tagDurations = durations select where select (In t _ ts) = (ts, getLocalTime t) select c = ([], getLocalTime $ time c) + +showDurMap :: Int -> Bool -> Bool -> Bool -> Map String NominalDiffTime -> [String] +showDurMap indent bySnd natural secs m = + fmap formatLine (ordPairs $ toList m) + where ordPairs = if bySnd then sortOn snd else sortOn fst + showDT = if secs then showDiffTime else showRoughDiffTime + durConf = if natural then naturalDurationConfig + else manDurationConfig + formatLine (x, ndt) = replicate indent ' ' + ++ x ++ ": " ++ showDT durConf ndt diff --git a/t4-commands/Commands.hs b/t4-commands/Commands.hs index deb9e58..552e555 100644 --- a/t4-commands/Commands.hs +++ b/t4-commands/Commands.hs @@ -2,13 +2,13 @@ module Main where import T4.Data import T4.Storage +import T4.Report import qualified Util as U import Data.List +import Data.Map import Data.Time -import Data.Map (Map, toList) import Control.Monad import Options.Applicative -import T4.Report (categoryDurations, tagDurations) data Command = CmdIn { ccat :: Maybe Category , ctags :: [Tag] @@ -104,27 +104,20 @@ handle CmdTags = do clocks <- getClocks handle (CmdReport True True obl man secs) = do clocks <- getClocks putStrLn "Categories" - showDurMap 2 obl man secs $ categoryDurations clocks + printDurMap 2 obl man secs $ categoryDurations clocks putStrLn "Tags" - showDurMap 2 obl man secs $ tagDurations clocks + printDurMap 2 obl man secs $ tagDurations clocks handle (CmdReport c t obl man secs) = do clocks <- getClocks - when c $ showDurMap 0 obl man secs $ categoryDurations clocks - when t $ showDurMap 0 obl man secs $ tagDurations clocks + when c $ printDurMap 0 obl man secs $ categoryDurations clocks + when t $ printDurMap 0 obl man secs $ tagDurations clocks + +printDurMap :: Int -> Bool -> Bool -> Bool -> Map String NominalDiffTime -> IO () +printDurMap i o n s = mapM_ putStrLn . showDurMap i o n s getClocks :: IO [Clock] getClocks = loadDataFromDir =<< getStorageDirectory -showDurMap :: Int -> Bool -> Bool -> Bool -> Map String NominalDiffTime -> IO () -showDurMap indent bySnd natural secs m = do - forM_ (ordPairs $ toList m) $ \(x, ndt) -> do - putStr $ replicate indent ' ' - putStrLn $ x ++ ": " ++ showDT durConf ndt - where ordPairs = if bySnd then sortOn snd else sortOn fst - showDT = if secs then U.showDiffTime else U.showRoughDiffTime - durConf = if natural then U.naturalDurationConfig - else U.manDurationConfig - main :: IO () main = do cmd <- execParser opts diff --git a/test/T4/ReportSpec.hs b/test/T4/ReportSpec.hs index 0a345a6..781fddf 100644 --- a/test/T4/ReportSpec.hs +++ b/test/T4/ReportSpec.hs @@ -66,3 +66,30 @@ spec = do durs = tagDurations [c1, c2] forAll (elements $ tags c1) $ \tag -> durs ! tag `shouldBe` diff + + describe "Text reports" $ do + let testDM = M.fromList [ ("foo", secondsToNominalDiffTime 2*60*60) + , ("bar", secondsToNominalDiffTime 8*60*60+42)] + it "Basic text report" $ + showDurMap 0 False False False testDM + `shouldBe` ["bar: 1d 0h 0mi", "foo: 2h 0mi"] + it "Indented" $ + showDurMap 3 False False False testDM + `shouldBe` [" bar: 1d 0h 0mi", " foo: 2h 0mi"] + it "Sorted by duration" $ + showDurMap 0 True False False testDM + `shouldBe` ["foo: 2h 0mi", "bar: 1d 0h 0mi"] + it "Natural time instead of man-days" $ + showDurMap 0 True True False testDM + `shouldBe` ["foo: 2h 0mi", "bar: 8h 0mi"] + it "With seconds" $ + showDurMap 0 True False True testDM + `shouldBe` ["foo: 2h 0mi 0s", "bar: 1d 0h 0mi 42s"] + + prop "# Entries = # Lines" $ \dm -> + length (showDurMap 0 False False False dm) + `shouldBe` M.size dm + prop "Arbitrary indentation" $ \indent dm -> + indent >= 0 && M.size dm > 0 ==> + forAll (elements $ showDurMap indent False False False dm) $ \line -> + replicate indent ' ' `isPrefixOf` line From 47b0b27dbd53aa934d71005c1cf1085c11f4ab84 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Fri, 24 Oct 2025 17:01:08 +0200 Subject: [PATCH 13/18] Use cats OR tags in Commands, so get rid of indentation --- lib/T4/Report.hs | 8 +++--- t4-commands/Commands.hs | 58 ++++++++++++++++------------------------- test/T4/ReportSpec.hs | 18 ++++--------- 3 files changed, 30 insertions(+), 54 deletions(-) diff --git a/lib/T4/Report.hs b/lib/T4/Report.hs index 00058c5..a161177 100644 --- a/lib/T4/Report.hs +++ b/lib/T4/Report.hs @@ -16,12 +16,10 @@ tagDurations = durations select where select (In t _ ts) = (ts, getLocalTime t) select c = ([], getLocalTime $ time c) -showDurMap :: Int -> Bool -> Bool -> Bool -> Map String NominalDiffTime -> [String] -showDurMap indent bySnd natural secs m = - fmap formatLine (ordPairs $ toList m) +showDurMap :: Bool -> Bool -> Bool -> Map String NominalDiffTime -> [String] +showDurMap bySnd natural secs m = + fmap (\(x, ndt) -> x ++ ": " ++ showDT durConf ndt) (ordPairs $ toList m) where ordPairs = if bySnd then sortOn snd else sortOn fst showDT = if secs then showDiffTime else showRoughDiffTime durConf = if natural then naturalDurationConfig else manDurationConfig - formatLine (x, ndt) = replicate indent ' ' - ++ x ++ ": " ++ showDT durConf ndt diff --git a/t4-commands/Commands.hs b/t4-commands/Commands.hs index 552e555..d366960 100644 --- a/t4-commands/Commands.hs +++ b/t4-commands/Commands.hs @@ -7,7 +7,6 @@ import qualified Util as U import Data.List import Data.Map import Data.Time -import Control.Monad import Options.Applicative data Command = CmdIn { ccat :: Maybe Category @@ -17,8 +16,7 @@ data Command = CmdIn { ccat :: Maybe Category | CmdStatus | CmdCats | CmdTags - | CmdReport { crepCat :: Bool - , crepTags :: Bool + | CmdReport { crepTags :: Bool , ordByLen :: Bool , natDur :: Bool , showSecs :: Bool @@ -53,28 +51,22 @@ commandParser = hsubparser ) ) reportParser = - correct <$> switch ( long "categories" - <> short 'c' - <> help "Include categories in the report" - ) - <*> switch ( long "tags" - <> short 't' - <> help "Include tags in the report" - ) - <*> switch ( long "order-by-length" - <> short 'l' - <> help "Reports should be ordered by length" - ) - <*> switch ( long "natural-time" - <> short 'n' - <> help "Natural durations instead of man-days" - ) - <*> switch ( long "show-seconds" - <> short 's' - <> help "Show seconds" - ) - where correct False False = CmdReport True True - correct c t = CmdReport c t + CmdReport <$> switch ( long "tags" + <> short 't' + <> help "Show tags instead of categories" + ) + <*> switch ( long "order-by-length" + <> short 'l' + <> help "Reports should be ordered by length" + ) + <*> switch ( long "natural-time" + <> short 'n' + <> help "Natural durations instead of man-days" + ) + <*> switch ( long "show-seconds" + <> short 's' + <> help "Show seconds" + ) opts :: ParserInfo Command opts = info (commandParser <**> helper) @@ -101,19 +93,13 @@ handle CmdCats = do clocks <- getClocks mapM_ putStrLn (sort $ allCategories clocks) handle CmdTags = do clocks <- getClocks mapM_ putStrLn (sort $ allTags clocks) -handle (CmdReport True True obl man secs) = do +handle (CmdReport t obl man secs) = do clocks <- getClocks - putStrLn "Categories" - printDurMap 2 obl man secs $ categoryDurations clocks - putStrLn "Tags" - printDurMap 2 obl man secs $ tagDurations clocks -handle (CmdReport c t obl man secs) = do - clocks <- getClocks - when c $ printDurMap 0 obl man secs $ categoryDurations clocks - when t $ printDurMap 0 obl man secs $ tagDurations clocks + let durMap = (if t then tagDurations else categoryDurations) clocks + printDurMap obl man secs durMap -printDurMap :: Int -> Bool -> Bool -> Bool -> Map String NominalDiffTime -> IO () -printDurMap i o n s = mapM_ putStrLn . showDurMap i o n s +printDurMap :: Bool -> Bool -> Bool -> Map String NominalDiffTime -> IO () +printDurMap o n s = mapM_ putStrLn . showDurMap o n s getClocks :: IO [Clock] getClocks = loadDataFromDir =<< getStorageDirectory diff --git a/test/T4/ReportSpec.hs b/test/T4/ReportSpec.hs index 781fddf..75809be 100644 --- a/test/T4/ReportSpec.hs +++ b/test/T4/ReportSpec.hs @@ -71,25 +71,17 @@ spec = do let testDM = M.fromList [ ("foo", secondsToNominalDiffTime 2*60*60) , ("bar", secondsToNominalDiffTime 8*60*60+42)] it "Basic text report" $ - showDurMap 0 False False False testDM + showDurMap False False False testDM `shouldBe` ["bar: 1d 0h 0mi", "foo: 2h 0mi"] - it "Indented" $ - showDurMap 3 False False False testDM - `shouldBe` [" bar: 1d 0h 0mi", " foo: 2h 0mi"] it "Sorted by duration" $ - showDurMap 0 True False False testDM + showDurMap True False False testDM `shouldBe` ["foo: 2h 0mi", "bar: 1d 0h 0mi"] it "Natural time instead of man-days" $ - showDurMap 0 True True False testDM + showDurMap True True False testDM `shouldBe` ["foo: 2h 0mi", "bar: 8h 0mi"] it "With seconds" $ - showDurMap 0 True False True testDM + showDurMap True False True testDM `shouldBe` ["foo: 2h 0mi 0s", "bar: 1d 0h 0mi 42s"] - prop "# Entries = # Lines" $ \dm -> - length (showDurMap 0 False False False dm) + length (showDurMap False False False dm) `shouldBe` M.size dm - prop "Arbitrary indentation" $ \indent dm -> - indent >= 0 && M.size dm > 0 ==> - forAll (elements $ showDurMap indent False False False dm) $ \line -> - replicate indent ' ' `isPrefixOf` line From d46cc75c6e1540d4b238c555d74100e0701a88ec Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Fri, 24 Oct 2025 17:12:21 +0200 Subject: [PATCH 14/18] Add reports to TUI options --- t4-tui/TUI.hs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/t4-tui/TUI.hs b/t4-tui/TUI.hs index 13b160a..304cdad 100644 --- a/t4-tui/TUI.hs +++ b/t4-tui/TUI.hs @@ -1,11 +1,13 @@ import T4.Data import T4.Storage +import T4.Report import Util import Completion import Data.Char import Data.List import Data.Maybe import Data.Function +import Data.Map (Map) import Data.Time import qualified System.Console.Haskeline as H @@ -28,10 +30,14 @@ showState = putStrLn . maybe "No clock data yet" summary promptIn :: SimpleLocalTime -> IO (Maybe Clock) promptIn started = do showSpent started - choice <- run $ H.getInputChar "[o]ut - [c]hange - [q]uit: " + clocks <- loadDataFromDir =<< getStorageDirectory + choice <- run $ H.getInputChar + "[o]ut - [u]pdate - report [c]ategories - report [t]ags - [q]uit: " case choice of Just 'o' -> showSpent started >> Just . Out <$> getCurrentSLT - Just 'c' -> Just <$> clockIn + Just 'u' -> Just <$> clockIn + Just 'c' -> Nothing <$ report "Categories" (categoryDurations clocks) + Just 't' -> Nothing <$ report "Tags" (tagDurations clocks) _ -> return Nothing showSpent :: SimpleLocalTime -> IO () @@ -43,9 +49,13 @@ showSpent started = do promptOut :: IO (Maybe Clock) promptOut = do - choice <- run $ H.getInputChar "[i]n - [q]uit: " + clocks <- loadDataFromDir =<< getStorageDirectory + choice <- run $ H.getInputChar + "[i]n - report [c]ategories - report [t]ags - [q]uit: " case choice of Just 'i' -> Just <$> clockIn + Just 'c' -> Nothing <$ report "Categories" (categoryDurations clocks) + Just 't' -> Nothing <$ report "Tags" (tagDurations clocks) _ -> return Nothing clockIn :: IO Clock @@ -60,6 +70,19 @@ clockIn = do where parseCat = fmap $ dropWhile isSpace . dropWhileEnd isSpace parseTags = map (dropWhile (== '#')) . words . fromMaybe [] +report :: String -> Map String NominalDiffTime -> IO () +report prefix durMap = do + putStrLn "[s]econds - [l]ength-ordered - [n]atural time instead of man-days" + mOptions <- run $ H.getInputLine "Options: " + case mOptions of + Nothing -> return () + Just options -> do + putStrLn prefix + let optSecs = 's' `elem` options + optLen = 'l' `elem` options + optNat = 'n' `elem` options + mapM_ putStrLn $ showDurMap optLen optNat optSecs durMap + run :: H.InputT IO a -> IO a run = H.runInputTBehavior H.preferTerm H.defaultSettings From 8de491d669c5034b2da08d0bdf0942a3c0655df7 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Fri, 24 Oct 2025 20:08:30 +0200 Subject: [PATCH 15/18] Fix minor empty string default Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- t4-tui/TUI.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t4-tui/TUI.hs b/t4-tui/TUI.hs index 304cdad..6f3e376 100644 --- a/t4-tui/TUI.hs +++ b/t4-tui/TUI.hs @@ -68,7 +68,7 @@ clockIn = do mtags <- runWithCompletion tagsCompl $ H.getInputLine "Tags: " return $ In now (parseCat mc) (parseTags mtags) where parseCat = fmap $ dropWhile isSpace . dropWhileEnd isSpace - parseTags = map (dropWhile (== '#')) . words . fromMaybe [] + parseTags = map (dropWhile (== '#')) . words . fromMaybe "" report :: String -> Map String NominalDiffTime -> IO () report prefix durMap = do From b5ca3bd19c80e1ad981995fb82865bf0314a58d1 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Fri, 24 Oct 2025 20:10:55 +0200 Subject: [PATCH 16/18] Fix minor typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/CompletionSpec.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/CompletionSpec.hs b/test/CompletionSpec.hs index 43fc676..6345a3f 100644 --- a/test/CompletionSpec.hs +++ b/test/CompletionSpec.hs @@ -130,7 +130,7 @@ genShortSublists xs = do genCompletions :: Gen (Completion String) genCompletions = do - ws <- listOf $ listOf $ arbitrary `suchThat` (not.isSpace) + ws <- listOf $ listOf $ arbitrary `suchThat` (not . isSpace) return $ Compl ws id notEmpty :: [a] -> Bool From 7c7218982ae215e29f7c60a1eefc639e3aa12460 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Sat, 25 Oct 2025 13:10:35 +0200 Subject: [PATCH 17/18] Cleanup executable source directory names --- {t4-commands => exe-t4-commands}/Commands.hs | 0 {t4-tui => exe-t5-interactive}/TUI.hs | 0 terminal-time-tracking-tool.cabal | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) rename {t4-commands => exe-t4-commands}/Commands.hs (100%) rename {t4-tui => exe-t5-interactive}/TUI.hs (100%) diff --git a/t4-commands/Commands.hs b/exe-t4-commands/Commands.hs similarity index 100% rename from t4-commands/Commands.hs rename to exe-t4-commands/Commands.hs diff --git a/t4-tui/TUI.hs b/exe-t5-interactive/TUI.hs similarity index 100% rename from t4-tui/TUI.hs rename to exe-t5-interactive/TUI.hs diff --git a/terminal-time-tracking-tool.cabal b/terminal-time-tracking-tool.cabal index 924171d..5b42bd1 100644 --- a/terminal-time-tracking-tool.cabal +++ b/terminal-time-tracking-tool.cabal @@ -48,13 +48,13 @@ executable t4 build-depends: terminal-time-tracking-tool , optparse-applicative , mtl - hs-source-dirs: t4-commands + hs-source-dirs: exe-t4-commands main-is: Commands.hs executable t5 import: basics build-depends: terminal-time-tracking-tool - hs-source-dirs: t4-tui + hs-source-dirs: exe-t5-interactive main-is: TUI.hs test-suite library-test From 7137b0caeb12bd7f99b788de3e6df64d51b5f474 Mon Sep 17 00:00:00 2001 From: Mirko Westermeier Date: Mon, 27 Oct 2025 10:45:01 +0100 Subject: [PATCH 18/18] Add more details to README --- README.md | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d9ec399..3e691af 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,95 @@ # t4: terminal time tracking tool -Haskell library and terminal GUI tool for time tracking. +**Haskell library and command line tools for simple time tracking.** -## Run command tool +Storage uses very human-friendly yaml files in `~/.t4-data` that can be edited manually: - cabal run t4 +``` +memowe@rakete:~$ ls ~/.t4-data +2025-10-15.yml 2025-10-25.yml 2025-10-24.yml +2025-10-23.yml 2025-10-27.yml +``` -## Run t4 TUI +```yaml +$ cat ~/.t4-data/2025-10-27.yml +- in: + category: Writing t4 README + tags: + - t4 + - haskell + - documentation + time: 2025-10-27 10:24:57 +- out: + time: 2025-10-27 10:29:12 +- in: + category: Lunch break + tags: + - break + - recreation + - nom + time: 2025-10-27 12:42:17 +``` - cabal run t5 +The project offers two command-line interfaces to edit these files for you. -## Run tests +## Preparations +This is a standard [GHC][ghc]/[cabal][cabal] project with a core [library](lib) and two executables [t4](exe-t4-commands)/[t5](exe-t5-interactive), so you can use the standard cabal commands to build dependencies, the project itself and run tests: + + cabal build --only-dependencies --enable-tests + cabal build cabal test --test-show-details=direct +You can also build the core library's [Haddock API docs][haddock] by yourself (although they are not extensively commented): + + cabal haddock + +## Command-based terminal interface `t4` + +You can run the command-based tool (*terminal time tracking tool*) without installing using `cabal run t4 -- ARGUMENTS` and install it via `cabal install`. + +``` +$ t4 --help +t4 - terminal time tracking tool + +Usage: t4 COMMAND + + Simple interface for clocking in and out + +Available options: + -h,--help Show this help text + +Available commands: + in Clocking in + out Clocking out + status Show current status + categories List all categories + tags List all tags + report Report + +$ t4 status +IN (2025-10-27 10:24:57) [Writing t4 README] #t4 #haskell #documentation +``` + +## Interactive terminal interface `t5` + +You can run the interactive terminal interface (*terminal time tracking tool terminal user interface*) without installing using `cabal run t5` and install it via `cabal install`. + +``` +$ t5 +IN (2025-10-27 10:24:57) [Writing t4 README] #t4 #haskell #documentation +Spent: 4mi 5s +[o]ut - [u]pdate - report [c]ategories - report [t]ags - [q]uit: o +Spent: 4mi 15s +OUT (2025-10-27 10:29:12) +``` + ## Author and license (c) 2025 Mirko Westermeier Released under the MIT license. See [LICENSE](LICENSE) for details. + +[ghc]: https://www.haskell.org/ghc/ +[cabal]: https://www.haskell.org/cabal/ +[haddock]: http://mirko.westermeier.de/t4/