From edfa9bac76c46667961f9ac13491d0d990e87a67 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Mon, 25 May 2026 21:48:51 -0400 Subject: [PATCH] docs: add implementation plan for cooking log feature --- docs/plans/2026-05-25-cooking-log.md | 704 +++++++++++++++++++++++++++ 1 file changed, 704 insertions(+) create mode 100644 docs/plans/2026-05-25-cooking-log.md diff --git a/docs/plans/2026-05-25-cooking-log.md b/docs/plans/2026-05-25-cooking-log.md new file mode 100644 index 0000000..e138bbf --- /dev/null +++ b/docs/plans/2026-05-25-cooking-log.md @@ -0,0 +1,704 @@ +# 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 +```