# Cooking Log Implementation Plan **Goal:** Allow users to log dates and comments when cooking a recipe, displayed on the recipe page and viewable across all recipes by date. **Architecture:** A new `Roux.CookLog` module encapsulates SQLite operations for a single `cook_log` table. The DB connection is opened at server startup and passed to handlers that need it. Frontend rendering uses SSR with a lightweight JS form submitter. **Tech Stack:** sqlite-simple for SQLite, native `` for the date picker, inline JS for form submission. --- ### Task 1: Add sqlite-simple dependency **Files:** - Modify: `package.yaml` - [ ] **Step 1: Add sqlite-simple to dependencies** Add `sqlite-simple` to the dependency list in `package.yaml`: ```yaml dependencies: - sqlite-simple ``` - [ ] **Step 2: Run hpack to regenerate .cabal** ```bash ./hs hpack ``` Expected: exits 0, `roux-server.cabal` updated with sqlite-simple. - [ ] **Step 3: Verify it builds** ```bash ./hs stack build ``` Expected: builds successfully. - [ ] **Step 4: Commit** ```bash git add package.yaml roux-server.cabal git commit -m "chore: add sqlite-simple dependency" ``` --- ### Task 2: Create Roux.CookLog module **Files:** - Create: `src/Roux/CookLog.hs` This module owns the `CookEntry` type and all DB operations. - [ ] **Step 1: Create the module with types and DB operations** ```haskell {-# LANGUAGE OverloadedStrings #-} {- | Cook log: track which recipes were cooked when, with optional notes. Uses sqlite-simple for persistence. The database file lives alongside the recipe directory as @cook-log.db@. -} module Roux.CookLog ( CookEntry (..), initDb, insertEntry, fetchEntries, fetchAllEntries, ) where import Data.Aeson (ToJSON, FromJSON) import qualified Data.Aeson as A import Data.Text (Text) import qualified Data.Text as T import Data.Time.Calendar (Day, fromGregorianValid) import Data.Time.Clock (UTCTime, getCurrentTime) import qualified Data.Time.Format as Time import Database.SQLite.Simple (Connection, FromRow (..), Only (..), Query) import qualified Database.SQLite.Simple as SQL -- | A single cooking log entry. data CookEntry = CookEntry { ceId :: !Int , ceRecipeFilename :: !Text , ceCookedDate :: !Day , ceComment :: !Text , ceCreatedAt :: !UTCTime } deriving stock (Eq, Show) instance FromRow CookEntry where fromRow = CookEntry <$> SQL.field <*> SQL.field <*> SQL.field <*> SQL.field <*> SQL.field instance ToJSON CookEntry where toJSON e = A.object [ "id" .= A.toJSON (ceId e) , "recipe_filename" .= A.toJSON (ceRecipeFilename e) , "cooked_date" .= A.toJSON (show (ceCookedDate e)) , "comment" .= A.toJSON (ceComment e) , "created_at" .= A.toJSON (show (ceCreatedAt e)) ] instance FromJSON CookEntry where parseJSON = A.withObject "CookEntry" $ \o -> CookEntry <$> o A..: "id" <*> o A..: "recipe_filename" <*> (parseDate =<< o A..: "cooked_date") <*> o A..: "comment" <*> (parseUTC =<< o A..: "created_at") where parseDate t = case T.splitOn "-" t of [y, m, d] -> case fromGregorianValid (read (T.unpack y)) (read (T.unpack m)) (read (T.unpack d)) of Just day -> pure day Nothing -> A.Fail "invalid date" _ -> A.Fail "expected YYYY-MM-DD" parseUTC t = case T.unpack t of "" -> A.Fail "empty datetime" s -> case Time.parseTimeM True Time.defaultTimeLocale "%Y-%m-%d %H:%M:%S" s of Just ut -> pure ut Nothing -> A.Fail "invalid datetime" -- | Open or create the DB, ensuring the table exists. initDb :: FilePath -> IO Connection initDb dbPath = do conn <- SQL.open dbPath SQL.execute_ conn createTableSQL pure conn where createTableSQL :: Query createTableSQL = "CREATE TABLE IF NOT EXISTS cook_log ( \ \ id INTEGER PRIMARY KEY AUTOINCREMENT, \ \ recipe_filename TEXT NOT NULL, \ \ cooked_date TEXT NOT NULL, \ \ comment TEXT NOT NULL DEFAULT '', \ \ created_at TEXT NOT NULL \ \)" -- | Insert a new cook log entry. Returns the new row id. insertEntry :: Connection -> Text -> Day -> Maybe Text -> IO Int insertEntry conn filename day mComment = do now <- getCurrentTime SQL.withTransaction conn $ do SQL.execute conn "INSERT INTO cook_log (recipe_filename, cooked_date, comment, created_at) VALUES (?, ?, ?, ?)" (filename, show day, T.strip (fromMaybe "" mComment), show now) fromOnly <$> SQL.query_ conn "SELECT last_insert_rowid()" where fromMaybe d Nothing = d fromMaybe _ (Just x) = x -- | Fetch all entries for a given recipe, newest first. fetchEntries :: Connection -> Text -> IO [CookEntry] fetchEntries conn filename = SQL.query conn "SELECT id, recipe_filename, cooked_date, comment, created_at \ \ FROM cook_log WHERE recipe_filename = ? ORDER BY cooked_date DESC, id DESC" (Only filename) -- | Fetch all entries across all recipes, newest first. fetchAllEntries :: Connection -> IO [CookEntry] fetchAllEntries conn = SQL.query_ conn "SELECT id, recipe_filename, cooked_date, comment, created_at \ \ FROM cook_log ORDER BY cooked_date DESC, id DESC" ``` - [ ] **Step 2: Verify it compiles** ```bash ./hs stack build ``` Expected: compiles (module not yet imported anywhere, but should type-check). - [ ] **Step 3: Commit** ```bash git add src/Roux/CookLog.hs git commit -m "feat: add CookLog module with types and DB operations" ``` --- ### Task 3: Initialize DB in server startup and wire into routing **Files:** - Modify: `src/Roux/Server.hs` The `app` function opens the DB and passes the `Connection` to `handleRequest` and the new cook-log route handler. `handleRequest` passes it through to `Html.recipePage` so cook entries can be SSR'd. - [ ] **Step 1: Add import and DB init in `app`** Add import near the top: ```haskell import qualified Roux.CookLog as CookLog ``` In the `app` function, after `state <- newIORef ...`, open the DB: ```haskell let dbPath = recipeDir "cook-log.db" db <- CookLog.initDb dbPath putStrLn $ "[roux] cook log DB: " <> dbPath ``` Change the app lambda and postHandler to pass `db`: ```haskell let postHandler request respond = case Wai.pathInfo request of ["import"] -> runImportPipeline recipeDir config request respond ["import-file"] -> runImportPipeline recipeDir config request respond ["import-text"] -> runImportPipeline recipeDir config request respond ["upload-image"] -> uploadImageHandler recipeDir state request respond pathInfo | Wai.requestMethod request == "PATCH" -> handleTitlePatch recipeDir state request respond | Just filename <- dispatchLogPost pathInfo -> handleCookLogPost db filename request respond _ -> handleRequest state db request respond pure $ \request respond -> do ... ``` Add the dispatchLogPost helper near the routing section: ```haskell -- | Extract filename from /cook-log/{filename} POST path, if it matches. dispatchLogPost :: [Text] -> Maybe Text dispatchLogPost ["cook-log", filename] = Just filename dispatchLogPost _ = Nothing ``` Update `handleRequest` signature and calls: ```haskell handleRequest :: IORef (Map FilePath Idx.RecipeInfo) -> SQL.Connection -> Wai.Application handleRequest state db request respond = do recipes <- readIORef state ... ["recipes", rawPath] -> case lookupRecipe (urlDecodePath rawPath) recipes of Just info -> do entries <- CookLog.fetchEntries db (T.pack (map toLower (stripCookExt rawPath))) respond (htmlResponse (Html.recipePage info entries)) Nothing -> respond notFound ``` Also update the SSE event handler path — any existing `handleRequest` call sites need the `db` parameter too. - [ ] **Step 2: Update all call sites of handleRequest** Search for `handleRequest` calls and add the `db` parameter: ```haskell | Wai.requestMethod request == "PATCH" -> handleTitlePatch recipeDir state request respond | otherwise -> handleRequest state db request respond ``` ```haskell let postHandler ... = ... ``` - [ ] **Step 3: Implement the POST handler for cook-log** ```haskell -- | Handle POST /cook-log/{filename} handleCookLogPost :: SQL.Connection -> Text -> Wai.Request -> (Wai.Response -> IO ()) -> IO () handleCookLogPost db filename request respond = do body <- readRequestBody request let params = parseFormBody body dateStr = fromMaybe "" (lookup "cooked-date" params) comment = lookup "comment" params case parseDate dateStr of Just day -> do _ <- CookLog.insertEntry db filename day comment let resp = Wai.responseLBS HTTP.status200 [("Content-Type", "application/json")] (LB.fromStrict (encodeUtf8 "{\"ok\":true}")) respond resp Nothing -> let resp = Wai.responseLBS HTTP.status400 [("Content-Type", "application/json")] (LB.fromStrict (encodeUtf8 "{\"error\":\"Invalid date format\"}")) in respond resp -- | Parse a YYYY-MM-DD date string. parseDate :: Text -> Maybe Day parseDate t = case T.splitOn "-" t of [y, m, d] -> do y' <- readMaybe (T.unpack y) m' <- readMaybe (T.unpack m) d' <- readMaybe (T.unpack d) fromGregorianValid y' m' d' _ -> Nothing ``` Import needed for `fromGregorianValid`: ```haskell import Data.Time.Calendar (Day, fromGregorianValid) import Data.Maybe (fromMaybe) import Text.Read (readMaybe) ``` - [ ] **Step 4: Verify it compiles** ```bash ./hs stack build ``` Expected: compiles successfully. - [ ] **Step 5: Commit** ```bash git add src/Roux/Server.hs src/Roux/CookLog.hs git commit -m "feat: wire up cook log DB at server startup" ``` --- ### Task 4: Add cook log GET endpoint and rendering **Files:** - Modify: `src/Roux/Html.hs` The `recipePage` function gains an `[CookEntry]` parameter. The marginalia column renders them. A new `cookHistoryPage` renders the cross-date view. - [ ] **Step 1: Add cook history rendering helpers to Html.hs** Add imports at the top: ```haskell import Data.Time.Calendar (Day) import qualified Data.Time.Format as Time import qualified Roux.CookLog as CookLog ``` Add the cook log section renderer before the `renderRecipe` function: ```haskell -- | Render the cook log section in the marginalia column. renderCookLog :: [CookLog.CookEntry] -> Html renderCookLog [] = pure () renderCookLog entries = do H.h2 "Cook history" H.form ! A.class_ "roux-cook-log-form" ! H.dataAttribute "filename" (H.toValue "PLACEHOLDER") -- set by caller ! A.style "display: flex; gap: 0.3rem; margin-bottom: 0.5rem;" $ do H.input ! A.type_ "date" ! A.name "cooked-date" ! A.value (H.toValue todayStr) H.input ! A.type_ "text" ! A.name "comment" ! A.placeholder "Optional note..." ! A.maxlength "500" ! A.style "flex: 1;" H.button ! A.type_ "submit" $ "Log it" H.div ! A.class_ "roux-cook-entries" $ do mapM_ renderEntry entries where todayStr = show (fromGregorian y m d) where (y, m, d) = toGregorian (utctDay now) -- TODO: need current date -- Note: we'll pass today's date from the server instead of computing here -- | Render a single cook log entry. renderEntry :: CookLog.CookEntry -> Html renderEntry entry = do H.p ! A.style "margin: 0.3rem 0; font-size: 0.9rem;" $ do H.strong ! A.style "display: block; text-align: center; color: var(--muted);" $ H.toHtml ("── " <> formatDay (CookLog.ceCookedDate entry) <> " ──") unless (T.null (CookLog.ceComment entry)) $ H.span ! A.style "display: block; margin-top: 0.2rem;" $ H.toHtml (CookLog.ceComment entry) -- | Format a Day as "Mon DD" e.g. "Mar 15". formatDay :: Day -> Text formatDay = T.pack . Time.formatTime Time.defaultTimeLocale "%b %e" ``` - [ ] **Step 2: Update `recipePage` signature and rendering** Change `recipePage` to accept a `[CookLog.CookEntry]`: ```haskell recipePage :: Idx.RecipeInfo -> [CookLog.CookEntry] -> ByteString recipePage info entries = case Idx.riRecipe info of Left err -> page "Parse Error" $ do ... Right recipe -> page (titleText recipe) $ do renderRecipe (Idx.riFilename info) recipe entries ... ``` Update `renderRecipe`: ```haskell renderRecipe :: FilePath -> Recipe -> [CookLog.CookEntry] -> Html renderRecipe filename recipe entries = do ... H.div ! A.class_ "roux-grid" $ do H.div $ do H.h2 "Ingredients" mapM_ renderIngredientGroup sections H.div $ do H.h2 "Method" renderMethodSections sections H.div ! A.class_ "roux-marginalia" $ do ... renderCookLog filename entries let recipeTags = metaTags meta unless (null recipeTags) $ do H.div ! A.class_ "note" $ do H.h2 "Tags" ... ``` - [ ] **Step 3: Add cook history page** ```haskell -- | Render the cook history page (all entries across recipes). cookHistoryPage :: [(T.Text, [CookLog.CookEntry])] -> ByteString cookHistoryPage grouped = page "Cook History" $ do H.nav ! A.class_ "roux-navbar" $ do H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux" H.ul $ do H.li $ H.a ! A.href "/" $ "Recipes" H.li $ H.a ! A.href "/cook-history" $ "Cook History" H.h1 "Cook History" mapM_ renderMonthGroup grouped renderMonthGroup :: (T.Text, [CookLog.CookEntry]) -> Html renderMonthGroup (month, entries) = do H.h2 $ H.toHtml month H.ul ! A.style "list-style: none; padding: 0;" $ do mapM_ renderHistoryEntry entries renderHistoryEntry :: CookLog.CookEntry -> Html renderHistoryEntry entry = do H.li ! A.style "margin-bottom: 0.5rem;" $ do H.strong $ H.toHtml (formatDay (CookLog.ceCookedDate entry)) " — " H.a ! A.href (H.toValue ("/recipes/" <> CookLog.ceRecipeFilename entry <> ".cook")) $ H.toHtml (CookLog.ceRecipeFilename entry) unless (T.null (CookLog.ceComment entry)) $ do H.br H.span $ H.toHtml (CookLog.ceComment entry) ``` - [ ] **Step 4: Add GET cook-log endpoint in Server.hs** In `handleRequest`, add the cook-log routes before the catch-all: ```haskell ["cook-log", filename] -> do entries <- CookLog.fetchEntries db filename let body = A.encode entries respond $ Wai.responseLBS HTTP.status200 [("Content-Type", "application/json")] body ["cook-log"] -> do entries <- CookLog.fetchAllEntries db let grouped = groupEntries entries respond $ htmlResponse (Html.cookHistoryPage grouped) ``` Add the `Aeson` and `Data.Time.Format` imports if not already there: ```haskell import qualified Data.Aeson as A import qualified Data.Time.Format as Time ``` Add `groupEntries` helper: ```haskell -- | Group entries by month label (e.g. "March 2026"). groupEntries :: [CookLog.CookEntry] -> [(Text, [CookLog.CookEntry])] groupEntries [] = [] groupEntries entries = let monthKey d = T.pack $ Time.formatTime Time.defaultTimeLocale "%B %Y" d groups = Map.fromListWith (++) [(monthKey (CookLog.ceCookedDate e), [e]) | e <- entries] sortedMonths = reverse (Map.keys groups) in [(m, Map.findWithDefault [] m groups) | m <- sortedMonths] ``` - [ ] **Step 5: Verify it compiles** ```bash ./hs stack build ``` Expected: compiles successfully. - [ ] **Step 6: Commit** ```bash git add src/Roux/Html.hs src/Roux/Server.hs git commit -m "feat: add cook log rendering to recipe page and cook history view" ``` --- ### Task 5: Add JavaScript for "I cooked this" form submission **Files:** - Modify: `src/Roux/Html.hs` Add inline JS to submit the cook log form via `fetch()` without page reload. - [ ] **Step 1: Add cookLogJs inline script constant** ```haskell -- | Inline JavaScript for cook log form submission. cookLogJs :: Text cookLogJs = T.unlines [ "(function(){" , "'use strict';" , "var form = document.querySelector('.roux-cook-log-form');" , "if (!form) return;" , "var dateInput = form.querySelector('[name=cooked-date]');" , "if (dateInput && !dateInput.value) dateInput.value = new Date().toISOString().slice(0,10);" , "form.addEventListener('submit', async function(e) {" , " e.preventDefault();" , " var fd = new FormData(form);" , " var filename = form.dataset.filename;" , " var res = await fetch('/cook-log/' + encodeURIComponent(filename), {" , " method: 'POST'," , " body: fd" , " });" , " if (!res.ok) {" , " var err = await res.json();" , " alert(err.error || 'Failed to log cooking');" , " return;" , " }" , " var date = fd.get('cooked-date');" , " var comment = fd.get('comment');" , " var entryDiv = document.createElement('div');" , " entryDiv.style.margin = '0.3rem 0';" , " entryDiv.style.fontSize = '0.9rem';" , " var dateLine = document.createElement('strong');" , " dateLine.style.display = 'block';" , " dateLine.style.textAlign = 'center';" , " dateLine.style.color = 'var(--muted)';" , " dateLine.textContent = '\u2014\u2014 ' + date + ' \u2014\u2014';" , " entryDiv.appendChild(dateLine);" , " if (comment) {" , " var commentSpan = document.createElement('span');" , " commentSpan.style.display = 'block';" , " commentSpan.style.marginTop = '0.2rem';" , " commentSpan.textContent = comment;" , " entryDiv.appendChild(commentSpan);" , " }" , " var container = form.nextElementSibling;" , " if (container) container.prepend(entryDiv);" , " form.querySelector('[name=comment]').value = '';" , "});" , "})();" ] ``` - [ ] **Step 2: Include the script in the recipe page** Add `cookLogJs` to the `sseScript` helper or to `renderRecipe`: ```haskell sseScript = do H.script ! A.type_ "text/javascript" $ H.preEscapedText sseRecipeJs H.script ! A.type_ "text/javascript" $ H.preEscapedText timerJs H.script ! A.type_ "text/javascript" $ H.preEscapedText recipeImageUploadJs H.script ! A.type_ "text/javascript" $ H.preEscapedText titleEditJs H.script ! A.type_ "text/javascript" $ H.preEscapedText cookLogJs ``` - [ ] **Step 3: Fix the data-filename attribute on the form** The `renderCookLog` helper needs to receive the actual filename so the `data-filename` attribute is correct. Pass it as a parameter: ```haskell renderCookLog :: FilePath -> [CookLog.CookEntry] -> Html renderCookLog filename entries = do ... H.form ! A.class_ "roux-cook-log-form" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) ... ``` Update the call site in `renderRecipe`: ```haskell renderCookLog filename entries ``` - [ ] **Step 4: Verify it compiles** ```bash ./hs stack build ``` Expected: compiles successfully. - [ ] **Step 5: Commit** ```bash git add src/Roux/Html.hs git commit -m "feat: add JS for cook log form submission" ``` --- ### Task 6: Add nav link to cook history **Files:** - Modify: `src/Roux/Html.hs` The main navbar on the index page should link to cook history. - [ ] **Step 1: Add cook history link to the index page navbar** Find the `indexNav` or navbar rendering in `indexPage` and add: ```haskell H.ul $ do H.li $ H.a ! A.href "/" $ "Recipes" H.li $ H.a ! A.href "/cook-history" $ "Cook History" ``` - [ ] **Step 2: Commit** ```bash git add src/Roux/Html.hs git commit -m "feat: add cook history nav link" ``` --- ### Task 7: End-to-end verification - [ ] **Step 1: Full build** ```bash ./hs stack build ``` Expected: exits 0 with no warnings. - [ ] **Step 2: Start the server and test** ```bash ./hs stack exec roux-server -- --recipe-dir recipes --port 8080 ``` Expected: server starts, logs "[roux] cook log DB: recipes/cook-log.db". - [ ] **Step 3: Manual test — log a cooking event** ```bash curl -X POST http://localhost:8080/cook-log/test-recipe \ -d "cooked-date=2026-05-25" \ -d "comment=Delicious\!" ``` Expected: `{"ok":true}` - [ ] **Step 4: Manual test — fetch entries** ```bash curl http://localhost:8080/cook-log/test-recipe ``` Expected: JSON array with the entry just inserted. - [ ] **Step 5: Manual test — cook history page** Visit http://localhost:8080/cook-history in browser. Expected: page shows entries grouped by month. - [ ] **Step 6: Commit final state** ```bash git add -A git commit -m "feat: complete cooking log feature" git push origin main ```