705 lines
21 KiB
Markdown
705 lines
21 KiB
Markdown
# 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`:
|
|
|
|
```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
|
|
```
|