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