Skip to content

Instantly share code, notes, and snippets.

@gatlin
Last active June 14, 2026 02:14
Show Gist options
  • Select an option

  • Save gatlin/4bee9645b96e8b9e733960453b972f49 to your computer and use it in GitHub Desktop.

Select an option

Save gatlin/4bee9645b96e8b9e733960453b972f49 to your computer and use it in GitHub Desktop.
(partially) vibe coded bible reading planner
cabal-version: 3.0
name: bible-reading
version: 0.1.0.0
synopsis: Bible reading plan generator
description: A simple program to generate Bible reading schedules following the tradition of reading 3 chapters Monday-Saturday and 5 on Sunday.
license: MIT
license-file: LICENSE
author: Your Name
maintainer: your.email@example.com
homepage: https://github.com/yourusername/bible-reading
category: Text
build-type: Simple
executable bible-reading
main-is: Main.hs
other-modules: BiblePlan
hs-source-dirs: .
default-language: Haskell2010
build-depends:
base >= 4.14 && < 5,
time >= 1.9 && < 1.10,
text >= 1.2 && < 2,
vector >= 0.12 && < 0.13,
split >= 0.2 && < 0.3,
servant-server,
servant-lucid,
lucid,
warp,
http-api-data
ghc-options:
-threaded
-Werror
-O2
default-extensions:
OverloadedStrings
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE GeneralisedNewtypeDeriving #-}
module BiblePlan where
import qualified Data.Time as T
import qualified Data.Time.Calendar.WeekDate as WD
import qualified Data.Text as T
import qualified Data.Vector as V
import Text.Printf (printf)
import Text.Read (readMaybe)
import Data.List.Split (splitOn)
-- | Newtypes to prevent mixing global and local chapter indices
newtype GlobalChapterIndex = GlobalChapterIndex Int deriving (Eq, Ord, Num, Show)
newtype LocalChapterNumber = LocalChapterNumber Int deriving (Eq, Ord, Num, Show)
-- | Bible book data structure
data Book = Book
{ bookName :: T.Text
, bookChapters :: Int
} deriving (Show, Eq)
-- | Chapter reference (book name, chapter number)
type ChapterRef = (T.Text, LocalChapterNumber)
-- | Configuration for the reading cadence
data PlanConfig = PlanConfig
{ weeklySchedule :: [Int] -- [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
}
defaultConfig :: PlanConfig
defaultConfig = PlanConfig [5, 3, 3, 3, 3, 3, 3]
-- | Bible books data
bibleBooks :: V.Vector Book
bibleBooks = V.fromList
[ Book "Genesis" 50, Book "Exodus" 40, Book "Leviticus" 27, Book "Numbers" 36
, Book "Deuteronomy" 34, Book "Joshua" 24, Book "Judges" 21, Book "Ruth" 4
, Book "1 Samuel" 31, Book "2 Samuel" 24, Book "1 Kings" 22, Book "2 Kings" 25
, Book "1 Chronicles" 29, Book "2 Chronicles" 36, Book "Ezra" 10, Book "Nehemiah" 13
, Book "Esther" 10, Book "Job" 42, Book "Psalms" 150, Book "Proverbs" 31
, Book "Ecclesiastes" 12, Book "Song of Solomon" 8, Book "Isaiah" 66
, Book "Jeremiah" 52, Book "Lamentations" 5, Book "Ezekiel" 48, Book "Daniel" 12
, Book "Hosea" 14, Book "Joel" 3, Book "Amos" 9, Book "Obadiah" 1, Book "Jonah" 4
, Book "Micah" 7, Book "Nahum" 3, Book "Habakkuk" 3, Book "Zephaniah" 3
, Book "Haggai" 2, Book "Zechariah" 14, Book "Malachi" 4, Book "Matthew" 28
, Book "Mark" 16, Book "Luke" 24, Book "John" 21, Book "Acts" 28, Book "Romans" 16
, Book "1 Corinthians" 16, Book "2 Corinthians" 13, Book "Galatians" 6
, Book "Ephesians" 6, Book "Philippians" 4, Book "Colossians" 4
, Book "1 Thessalonians" 5, Book "2 Thessalonians" 3, Book "1 Timothy" 6
, Book "2 Timothy" 4, Book "Titus" 3, Book "Philemon" 1, Book "Hebrews" 13
, Book "James" 5, Book "1 Peter" 5, Book "2 Peter" 3, Book "1 John" 5
, Book "2 John" 1, Book "3 John" 1, Book "Jude" 1, Book "Revelation" 22
]
-- | Precomputed cumulative chapter counts.
-- Index i contains the sum of chapters of all books before book i.
cumulativeChapters :: V.Vector Int
cumulativeChapters = V.fromList $ scanl (+) 0 (V.toList $ V.map bookChapters bibleBooks)
totalBibleChapters :: Int
totalBibleChapters = V.last cumulativeChapters
-- | Convert a ChapterRef to a global index
toGlobalIndex :: ChapterRef -> Either String GlobalChapterIndex
toGlobalIndex (name, LocalChapterNumber chap) = do
idx <- findBookIndex name
let offset = cumulativeChapters V.! idx
if chap <= 0 || chap > (bookChapters (bibleBooks V.! idx))
then Left $ printf "Invalid chapter %d for book %s" chap (T.unpack name)
else Right $ GlobalChapterIndex (offset + chap)
-- | Convert a global index back to a ChapterRef
fromGlobalIndex :: GlobalChapterIndex -> ChapterRef
fromGlobalIndex (GlobalChapterIndex global) =
let bookIdx = case V.findIndex (\c -> c >= global) cumulativeChapters of
Just i -> i - 1
Nothing -> V.length bibleBooks - 1
book = bibleBooks V.! bookIdx
localChap = LocalChapterNumber (global - (cumulativeChapters V.! bookIdx))
in (bookName book, localChap)
-- | Find the index of a book by its name (case-insensitive prefix match)
findBookIndex :: T.Text -> Either String Int
findBookIndex name =
let lowerName = T.toLower name
in case V.findIndex (\b -> lowerName `T.isPrefixOf` T.toLower (bookName b)) bibleBooks of
Just idx -> Right idx
Nothing -> Left $ "Unknown book: " ++ T.unpack name
-- | Generate the reading plan
generateReadingPlan :: PlanConfig -> T.Day -> ChapterRef -> [T.Text]
generateReadingPlan config startDate startChapter =
case toGlobalIndex startChapter of
Left err -> [T.pack $ "Error: " ++ err]
Right startGlobal ->
let days = map (`T.addDays` startDate) [0..]
plan = [ (weeklySchedule config) !! dayOfWeek d | d <- days ]
-- Calculate the global index for the end of each day's reading
allEnds = tail $ scanl (\(GlobalChapterIndex acc) chapters -> GlobalChapterIndex (acc + chapters))
startGlobal plan
-- The plan ends on the first day that reaches or exceeds the total Bible chapters
numDays = case V.findIndex (\(GlobalChapterIndex g) -> g >= totalBibleChapters) (V.fromList allEnds) of
Just i -> i + 1
Nothing -> 0
-- The start chapter for each day is the end of the previous day (or the initial startGlobal)
allStarts = startGlobal : allEnds
relevantStarts = take numDays allStarts
targets = map fromGlobalIndex relevantStarts
relevantDays = take numDays days
relevantCounts = take numDays plan
in if numDays == 0
then []
else zipWith3 formatDayReading (init relevantDays) (init targets) (init relevantCounts)
++ [formatLastDayReading (last relevantDays) (last targets)]
formatDayReading :: T.Day -> ChapterRef -> Int -> T.Text
formatDayReading day (book, LocalChapterNumber chap) count =
T.pack $ printf "%s: %s %d (%d chapters)" (formatDay day) (T.unpack book) chap count
formatLastDayReading :: T.Day -> ChapterRef -> T.Text
formatLastDayReading day (book, LocalChapterNumber chap) =
T.pack $ printf "%s: %s %d (remainder)" (formatDay day) (T.unpack book) chap
formatDay :: T.Day -> String
formatDay = T.formatTime T.defaultTimeLocale "%A, %B %d, %Y"
dayOfWeek :: T.Day -> Int
dayOfWeek day = d `mod` 7 where (_, _, d) = WD.toWeekDate day
readDay :: String -> Either String T.Day
readDay str = case map readMaybe (splitOn "-" str) of
[Just y, Just m, Just d] -> Right $ T.fromGregorian (toInteger y) m d
_ -> Left "Invalid date format. Please use YYYY-MM-DD."
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveGeneric #-}
import GHC.Generics (Generic)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Network.Wai.Handler.Warp (run)
import Servant
import Servant.HTML.Lucid (HTML)
import Lucid
import Web.FormUrlEncoded (FromForm)
import Control.Monad.IO.Class (liftIO)
import Data.Time (getCurrentTime, utctDay)
import Data.Time.Format (formatTime, defaultTimeLocale)
import BiblePlan
-- | Request body for the reading plan form
data PlanRequest = PlanRequest
{ startDate :: T.Text
, startBook :: T.Text
, startChapter :: Int
} deriving (Generic, Show)
instance FromForm PlanRequest
-- | The Servant API definition
type BibleAPI =
Get '[HTML] (Html ())
:<|> "plan" :> ReqBody '[FormUrlEncoded] PlanRequest :> Post '[HTML] (Html ())
-- | Common styles for a mobile-first experience
commonStyles :: Html ()
commonStyles = style_ $ T.concat
[ "body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; \
\margin: 0; padding: 20px; line-height: 1.6; color: #333; background-color: #fff; }\
\div { margin-bottom: 15px; }\
\input { width: 100%; padding: 12px; margin-top: 5px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; font-size: 16px; }\
\button { width: 100%; padding: 12px; background-color: #007AFF; color: white; border: none; border-radius: 4px; \
\font-size: 16px; font-weight: bold; cursor: pointer; }\
\label { font-weight: bold; display: block; }\
\a { color: #007AFF; text-decoration: none; }"
]
-- | The server implementation
server :: Server BibleAPI
server = showForm :<|> handlePlan
where
-- GET / : Renders the input form
showForm = do
now <- liftIO getCurrentTime
let today = T.pack $ formatTime defaultTimeLocale "%Y-%m-%d" (utctDay now)
return $ doctypehtml_ $ do
head_ $ do
meta_ [name_ "viewport", content_ "width=device-width, initial-scale=1.0"]
title_ "Bible Reading Plan Generator"
commonStyles
style_ "form { background: #f4f4f4; padding: 20px; border-radius: 8px; max-width: 500px; margin: 0 auto; }"
body_ $ do
h1_ [style_ "text-align: center;"] "Bible Reading Plan"
p_ [style_ "text-align: center; margin-bottom: 20px;"] "Generate your daily reading schedule."
form_ [method_ "POST", action_ "/plan"] $ do
div_ $ do
label_ "Start Date"
input_ [type_ "date", name_ "startDate", value_ today, required_ "required"]
div_ $ do
label_ "Start Book"
input_ [type_ "text", name_ "startBook", placeholder_ "e.g. Genesis", required_ "required"]
div_ $ do
label_ "Start Chapter"
input_ [type_ "number", name_ "startChapter", value_ "1", required_ "required"]
button_ [type_ "submit"] "Generate Plan"
-- POST /plan : Processes the form and renders the result
handlePlan req = do
let dateStr = T.unpack (startDate req)
bookNameStr = startBook req
chapNum = startChapter req
case readDay dateStr of
Left err -> return $ renderError err
Right day -> do
let startChapter = (bookNameStr, LocalChapterNumber chapNum)
plan = generateReadingPlan defaultConfig day startChapter
-- Check if the first element of the plan is an error message
if not (null plan) && T.isPrefixOf "Error: " (head plan)
then return $ renderError (T.unpack $ head plan)
else return $ renderPlan plan
-- | Helper to render the final plan as HTML
renderPlan :: [T.Text] -> Html ()
renderPlan plan = doctypehtml_ $ do
head_ $ do
meta_ [name_ "viewport", content_ "width=device-width, initial-scale=1.0"]
title_ "Your Bible Reading Plan"
commonStyles
style_ "ul { list-style: none; padding: 0; max-width: 600px; margin: 0 auto; }\
\li { padding: 12px 0; border-bottom: 1px solid #eee; font-size: 15px; }"
body_ $ do
h1_ [style_ "text-align: center;"] "Your Reading Plan"
div_ [style_ "text-align: center; margin-bottom: 20px;"] $
a_ [href_ "/"] "← Back to form"
hr_ [style_ "max-width: 600px; margin: 20px auto; border: 0; border-top: 1px solid #eee;"]
ul_ $ mapM_ (li_ . toHtml) plan
-- | Helper to render an error message
renderError :: String -> Html ()
renderError err = doctypehtml_ $ do
head_ $ do
meta_ [name_ "viewport", content_ "width=device-width, initial-scale=1.0"]
title_ "Error"
commonStyles
body_ $ do
h1_ [style_ "text-align: center;"] "Error"
p_ [style_ "text-align: center;"] (toHtml err)
div_ [style_ "text-align: center;"] $
a_ [href_ "/"] "← Try again"
-- | Main entry point
main :: IO ()
main = do
let port = 8080
putStrLn $ "Server running on http://localhost:" ++ show port
run port (serve (Proxy :: Proxy BibleAPI) server)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment