Files
roux/docs/plans/2026-05-25-cooking-log.md

21 KiB

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 <input type="date"> 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:

dependencies:
  - sqlite-simple
  • Step 2: Run hpack to regenerate .cabal
./hs hpack

Expected: exits 0, roux-server.cabal updated with sqlite-simple.

  • Step 3: Verify it builds
./hs stack build

Expected: builds successfully.

  • Step 4: Commit
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
{-# 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
./hs stack build

Expected: compiles (module not yet imported anywhere, but should type-check).

  • Step 3: Commit
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:

import qualified Roux.CookLog as CookLog

In the app function, after state <- newIORef ..., open the DB:

    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:

    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:

-- | 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:

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:

            | Wai.requestMethod request == "PATCH" ->
                handleTitlePatch recipeDir state request respond
            | otherwise -> handleRequest state db request respond
    let postHandler ... = ...
  • Step 3: Implement the POST handler for cook-log
-- | 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:

import Data.Time.Calendar (Day, fromGregorianValid)
import Data.Maybe (fromMaybe)
import Text.Read (readMaybe)
  • Step 4: Verify it compiles
./hs stack build

Expected: compiles successfully.

  • Step 5: Commit
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:

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:

-- | 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]:

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:

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
-- | 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:

        ["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:

import qualified Data.Aeson as A
import qualified Data.Time.Format as Time

Add groupEntries helper:

-- | 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
./hs stack build

Expected: compiles successfully.

  • Step 6: Commit
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
-- | 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:

    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:

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:

            renderCookLog filename entries
  • Step 4: Verify it compiles
./hs stack build

Expected: compiles successfully.

  • Step 5: Commit
git add src/Roux/Html.hs
git commit -m "feat: add JS for cook log form submission"

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:

    H.ul $ do
        H.li $ H.a ! A.href "/" $ "Recipes"
        H.li $ H.a ! A.href "/cook-history" $ "Cook History"
  • Step 2: Commit
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
./hs stack build

Expected: exits 0 with no warnings.

  • Step 2: Start the server and test
./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
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
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
git add -A
git commit -m "feat: complete cooking log feature"
git push origin main