summaryrefslogtreecommitdiff
path: root/Biz/Pie.hs
blob: ddaa6ee94f525978b3bcfde6526da95f3ef41065 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# 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 docopt
-- : dep haskeline
-- : dep protolude
-- : dep parsec
-- : dep tasty
-- : dep tasty-hunit
module Biz.Pie
  ( main,
  )
where

import Alpha
import qualified Biz.Cli as Cli
import Biz.Test ((@=?))
import qualified Biz.Test as Test
import qualified Data.Aeson as Aeson
import qualified Data.Maybe as Maybe
import qualified Data.Text as Text
import qualified Data.Time as Time
import qualified System.Console.Haskeline as Haskeline
import qualified System.Directory as Directory
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 = Cli.main <| Cli.Plan help move test

test :: Test.Tree
test = Test.group "Biz.Pie" [Test.unit "id" <| 1 @=? (1 :: Integer)]

help :: Cli.Docopt
help =
  [Cli.docopt|
[p]roduct [i]mprovement [e]ngine
manages .pie files, records data from product build sprints and user testing

Usage:
  pie new
  pie update <ns>
  pie feedback <ns>
  pie test
|]

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

fromArgs :: Cli.Arguments -> Move
fromArgs args
  | cmd "new" = New
  | cmd "update" = Update <| getArg "ns"
  | cmd "feedback" = Feedback <| getArg "ns"
  | otherwise = panic "could not get move from args"
  where
    cmd a = args `Cli.has` Cli.command a
    getArg a = Maybe.fromJust <| args `Cli.getArg` Cli.argument a

move :: Cli.Arguments -> IO ()
move args = case fromArgs args 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]
  Update namespace ->
    Haskeline.runInputT Haskeline.defaultSettings <| do
      form <- liftIO <| loadForm namespace
      timestamp <- liftIO Time.getCurrentTime
      onTrack <- parseBool </ question "Are you on track?"
      isLaunched <- parseBool </ question "Are you launched?"
      weeksUntilLaunch <- parseInt </ question "How many weeks to launch?"
      usersTalkedWith <- parseInt </ question "Haw many (prospective) users have you talked to in the last week?"
      learnings <- parseText </ question "What have you learned from them?"
      morale <- parseInt </ question "On a scale of 1-10, what is your morale?"
      mostImprovement <- parseText </ question "What most improved your primary metric?"
      biggestObstacle <- parseText </ question "What is your biggest obstacle?"
      goals <- parseText </ question "What are your top 1-3 goals for next week?"
      liftIO <| saveForm namespace <| form {roll = BuildSprint {..} : roll form}
  Feedback namespace ->
    Haskeline.runInputT Haskeline.defaultSettings <| do
      form <- liftIO <| loadForm namespace
      timestamp <- liftIO Time.getCurrentTime
      user <- parseText </ question "User?"
      howDisappointed <- parseDisappointment </ question "How disappointed? (1=very, 2=somewhat, 3=not)"
      liftIO <| saveForm namespace <| form {roll = UserFeedback {..} : roll form}

question :: String -> Haskeline.InputT IO String
question q = Maybe.fromJust </ (Haskeline.getInputLine <| q ++ " ")

data Entry
  = BuildSprint
      { timestamp :: Time.UTCTime,
        -- | Last week your goals were X. As of now, do you feel like you're on
        -- track to hit your goals?
        onTrack :: Bool,
        -- | Are you launched?
        isLaunched :: Bool,
        -- | How many weeks to launch?
        weeksUntilLaunch :: Int,
        -- | Haw many (prospective) users have you talked to in the last week?
        usersTalkedWith :: Int,
        -- | What have you learned from them?
        learnings :: Text,
        -- | On a scale of 1-10, what is your morale?
        morale :: Int,
        -- | What most improved your primary metric?
        mostImprovement :: Text,
        -- | What is your biggest obstacle?
        biggestObstacle :: Text,
        -- | What are your top 1-3 goals for next week?
        goals :: Text
      }
  | UserFeedback
      { timestamp :: Time.UTCTime,
        user :: Text,
        howDisappointed :: Disappointment
      }
  deriving (Generic, Aeson.ToJSON, Aeson.FromJSON, Show)

data Disappointment = Very | Somewhat | NotAtAll
  deriving (Generic, Aeson.ToJSON, Aeson.FromJSON, Show)

-- helpers for parsing user input

parseInput :: Parsec.Parser a -> 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