{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE NoImplicitPrelude #-} -- | [P]roduct [I]mprovement [E]ngine -- -- A product improvement engine must measure two things: -- -- 1. Is your product built? -- 2. Do you have product-market fit? -- -- Let's use an analogy: building a startup is like jumping off a clif and -- assembling a plane on the way down. As we approach the ground at terminal -- velocity, only two questions are relevant: Is the plane built? Does it fly? -- Nothing else matters. -- -- So, Pie is a program that records answers to these two things and then -- reports on whether we are making the correct progress. -- -- This is inspired by a few things: -- -- - YC's Startup School has a build sprint questionnaire -- - Sam Altman's startup playbook: "You want to build a 'product improvement -- engine' in your company." -- - Sean Ellis' question: "How would you feel if you could no longer use this -- product? (a) Very disappointed, (b) somewhat disappointed, (c) not -- disappointed" and then measure the percentage who answer (a). -- -- Bild Metadata: -- -- : out pie -- : dep aeson -- : dep protolude -- : dep optparse-simple -- : dep parsec -- : dep haskeline module Biz.Pie ( main, ) where import Alpha import qualified Data.Aeson as Aeson import qualified Data.List as List import qualified Data.Maybe as Maybe import qualified Data.Text as Text import qualified Data.Time as Time import Options.Applicative.Simple import qualified System.Console.Haskeline as Haskeline import qualified System.Directory as Directory import qualified System.Environment as Env import qualified System.Exit as Exit import qualified System.Process as Process import qualified Text.Parsec as Parsec import qualified Text.Parsec.String as Parsec main :: IO () main = do (ns, mov) <- simpleOptions "dev" "[P]roduct [I]mprovement [E]ngine" "manages .pie files, records data from product build sprints and user testing" (pure mempty) <| do addCommand "new" "start new a weekly sprint" (const <. move New) (pure mempty) addCommand "update" "submit weekly update for a namespace" (move <. Update) optNamespace addCommand "feedback" "submit user feedback for a namespace" (move <. Feedback) optNamespace cwd <- Directory.getCurrentDirectory root <- Env.getEnv "BIZ_ROOT" let fullNamespace = require "namespace" <| List.stripPrefix "/" <| ((cwd List.\\ root) <> "/" <> Text.unpack ns) putText <| "ns:" <> ns putText <| "fullNamespace:" <> ns oldForm <- loadForm fullNamespace newForm <- mov oldForm saveForm (Text.unpack ns) newForm optNamespace :: Parser String optNamespace = strOption <| help "the namespace of the app you are working on" <> long "namespace" <> short 'n' <> value "Devalloc" newtype Form = Form {roll :: [Entry]} deriving (Generic, Aeson.ToJSON, Aeson.FromJSON, Show) instance Monoid Form where mempty = Form [] instance Semigroup Form where a <> b = Form (roll a <> roll b) formFile :: String -> FilePath formFile ns = ns ++ ".pie" loadForm :: String -> IO Form loadForm ns = Directory.doesFileExist file >>= \case False -> return mempty True -> Aeson.decodeFileStrict file >>= \case Nothing -> panic <| Text.pack <| "could not decode: " ++ file Just x -> return x where file = formFile ns saveForm :: String -> Form -> IO () saveForm "" _ = pure () saveForm namespace form = Aeson.encodeFile (formFile namespace) form data Move = New | Update String | Feedback String move :: Move -> Form -> IO Form move mov form = case mov of New -> do week <- Time.getCurrentTime >>= return <. Time.formatTime Time.defaultTimeLocale "%V" let branch = "sprint-" <> week proc <- Process.spawnProcess "git" ["show-ref", branch] Process.waitForProcess proc >>= \case Exit.ExitSuccess -> Process.callProcess "git" ["switch", branch] Exit.ExitFailure _ -> Process.callProcess "git" ["switch", "-c", branch] >> return form Update namespace -> Haskeline.runInputT Haskeline.defaultSettings <| do timestamp <- liftIO Time.getCurrentTime onTrack <- parseBool Haskeline.runInputT Haskeline.defaultSettings <| do timestamp <- liftIO Time.getCurrentTime user <- parseText Haskeline.InputT IO String question q = Maybe.fromJust String -> Either Parsec.ParseError a parseInput p = Parsec.parse (p <* Parsec.eof) "" parseDisappointment :: String -> Disappointment parseDisappointment s = case parseInt s of 1 -> Very 2 -> Somewhat 3 -> NotAtAll _ -> panic "could not parse disappointment" parseText :: String -> Text parseText s = parseInput (Parsec.many1 Parsec.anyChar) s |> fromRight "" |> Text.pack parseBool :: String -> Bool parseBool s = parseInput (Parsec.oneOf "yn") s /> (== 'y') |> fromRight False parseInt :: String -> Int parseInt s = parseInput (Parsec.many1 Parsec.digit /> readMaybe) s /> Maybe.fromJust |> fromRight 0