Last active
June 14, 2026 02:14
-
-
Save gatlin/4bee9645b96e8b9e733960453b972f49 to your computer and use it in GitHub Desktop.
(partially) vibe coded bible reading planner
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| {-# 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." |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| {-# 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