feat: add interactive countdown timers to recipe steps
Build and Deploy / build-and-deploy (push) Successful in 1m45s

Cooklang timer references (~{15%minutes}) now render as clickable
spans. Clicking one starts a live countdown displayed in-place
with MM:SS format. A reset button (↺) restarts the timer from
the original duration. A close button (✕) returns to the static
label. When the timer reaches zero, the display pulses and turns
rust-colored for 3 cycles.

Implementation:
- durationToSeconds helper converts Duration to total seconds
- Timer span uses data-seconds attribute for the total
- Label + controls (display, reset, close) structure pre-rendered
  in HTML, toggled via a .running CSS class
- Timer JS initializes all .roux-timer-tag elements on page load
- Each timer manages its own setInterval, multiple timers can
  run simultaneously across different steps
This commit is contained in:
2026-05-20 22:50:35 -04:00
parent ddb8577c4b
commit 5abf5490b4
+114 -6
View File
@@ -232,6 +232,19 @@ page title content =
, ".tag { display: inline-block; border: 1px solid rgba(122, 154, 122, 0.35); padding: 0.1rem 0.5rem; margin: 0.1rem; border-radius: 4px; font-size: 0.7rem; color: var(--roux-sage); background: transparent; }"
, ".r-section-header { display: block; font-family: \"Fraunces\", serif; font-size: 0.85rem; font-weight: 500; color: var(--roux-text); opacity: 0.5; margin: 1rem 0 0.5rem; letter-spacing: 0.01em; border-top: 0.5px solid rgba(61, 44, 30, 0.06); padding-top: 0.75rem; }"
, ".r-section-header:first-child { border-top: none; margin-top: 0; padding-top: 0; }"
, ""
, "/* Interactive timer */"
, ".roux-timer-tag { cursor: pointer; border-bottom: 1px dashed rgba(61, 44, 30, 0.25); padding: 0 1px; }"
, ".roux-timer-tag:hover { background: rgba(61, 44, 30, 0.04); border-radius: 2px; }"
, ".roux-timer-tag .roux-timer-controls { display: none; }"
, ".roux-timer-tag.running .roux-timer-label { display: none; }"
, ".roux-timer-tag.running .roux-timer-controls { display: inline-flex; align-items: center; gap: 4px; }"
, ".roux-timer-display { font-variant-numeric: tabular-nums; font-weight: 500; min-width: 3.5em; text-align: center; }"
, ".roux-timer-display.done { color: var(--roux-accent); }"
, ".roux-timer-display.pulse { animation: roux-timer-pulse 0.5s ease-in-out 3; }"
, "@keyframes roux-timer-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }"
, ".roux-timer-reset, .roux-timer-close { background: none; border: none; cursor: pointer; font-size: 0.75rem; padding: 0 2px; line-height: 1; color: rgba(61, 44, 30, 0.4); transition: color 0.15s; }"
, ".roux-timer-reset:hover, .roux-timer-close:hover { color: var(--roux-accent); }"
]
H.body $ H.main ! A.class_ "container" $ content
@@ -414,6 +427,75 @@ sseRecipeJs =
]
-- | Inline JavaScript for SSE live-reload on the index page.
-- | Inline JavaScript for interactive recipe timers.
timerJs :: Text
timerJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "var timers = document.querySelectorAll('.roux-timer-tag');"
, "timers.forEach(function(el) {"
, " var total = parseInt(el.getAttribute('data-seconds'), 10);"
, " if (!total || total <= 0) return;"
, " var remaining = total;"
, " var intervalId = null;"
, " var labelEl = el.querySelector('.roux-timer-label');"
, " var displayEl = el.querySelector('.roux-timer-display');"
, " var resetBtn = el.querySelector('.roux-timer-reset');"
, " var closeBtn = el.querySelector('.roux-timer-close');"
, " function formatTime(s) {"
, " var m = Math.floor(s / 60);"
, " var sec = s % 60;"
, " return m + ':' + (sec < 10 ? '0' : '') + sec;"
, " }"
, " function updateDisplay() {"
, " displayEl.textContent = formatTime(remaining);"
, " displayEl.classList.remove('done', 'pulse');"
, " if (remaining <= 0) {"
, " displayEl.classList.add('done');"
, " displayEl.classList.add('pulse');"
, " }"
, " }"
, " function startTimer() {"
, " if (intervalId) return;"
, " el.classList.add('running');"
, " updateDisplay();"
, " intervalId = setInterval(function() {"
, " remaining -= 1;"
, " if (remaining <= 0) {"
, " remaining = 0;"
, " clearInterval(intervalId);"
, " intervalId = null;"
, " }"
, " updateDisplay();"
, " }, 1000);"
, " }"
, " function resetTimer() {"
, " if (intervalId) { clearInterval(intervalId); intervalId = null; }"
, " remaining = total;"
, " updateDisplay();"
, " if (!el.classList.contains('running')) { el.classList.add('running'); }"
, " }"
, " function closeTimer() {"
, " if (intervalId) { clearInterval(intervalId); intervalId = null; }"
, " el.classList.remove('running');"
, " remaining = total;"
, " }"
, " el.addEventListener('click', function(e) {"
, " if (e.target === resetBtn || e.target === closeBtn) return;"
, " if (!el.classList.contains('running')) { startTimer(); }"
, " });"
, " if (resetBtn) resetBtn.addEventListener('click', function(e) {"
, " e.stopPropagation(); resetTimer();"
, " });"
, " if (closeBtn) closeBtn.addEventListener('click', function(e) {"
, " e.stopPropagation(); closeTimer();"
, " });"
, "});"
, "})();"
]
sseIndexJs :: Text
sseIndexJs =
T.unlines
@@ -518,8 +600,9 @@ recipePage info =
hiddenRecipeSpan rInfo =
H.span ! A.id "roux-current-recipe" ! A.style "display: none;" $
H.toHtml (T.pack (Idx.riFilename rInfo))
sseScript =
sseScript = do
H.script ! A.type_ "text/javascript" $ H.preEscapedText sseRecipeJs
H.script ! A.type_ "text/javascript" $ H.preEscapedText timerJs
-- | Render the body of a recipe page (no page shell).
renderRecipe :: Recipe -> Html
@@ -622,6 +705,26 @@ showDuration d =
unit = fromMaybe "" (durationUnit d)
in T.strip (amount <> " " <> unit)
-- | Convert a Duration to total seconds for interactive timer display.
durationToSeconds :: Duration -> Int
durationToSeconds d =
let amt = fromRational (durationAmount d) :: Double
unit = fmap T.toLower (durationUnit d)
multiplier = case unit of
Just "hours" -> 3600.0
Just "hour" -> 3600.0
Just "h" -> 3600.0
Just "minutes" -> 60.0
Just "minute" -> 60.0
Just "min" -> 60.0
Just "m" -> 60.0
Just "seconds" -> 1.0
Just "second" -> 1.0
Just "sec" -> 1.0
Just "s" -> 1.0
_ -> 60.0
in round (amt * multiplier)
-- | Roughly convert Rational to Double for display.
toDouble :: Rational -> Double
toDouble r = fromIntegral (numerator r) / fromIntegral (denominator r)
@@ -727,11 +830,16 @@ renderStepItem (StepCookware cw) =
H.span ! A.class_ "roux-cookware-tag" $
H.toHtml (cwName cw)
renderStepItem (StepTimer timer) =
H.span ! A.class_ "roux-timer-tag" $ do
case timerName timer of
Just n -> H.toHtml n >> " "
Nothing -> pure ()
H.toHtml (showDuration (timerDuration timer))
let secs = T.pack (show (durationToSeconds (timerDuration timer)))
label = case timerName timer of
Just n -> n <> " " <> showDuration (timerDuration timer)
Nothing -> showDuration (timerDuration timer)
in H.span ! H.dataAttribute "seconds" (H.toValue secs) ! A.class_ "roux-timer-tag" $ do
H.span ! A.class_ "roux-timer-label" $ H.toHtml label
H.span ! A.class_ "roux-timer-controls" $ do
H.span ! A.class_ "roux-timer-display" $ H.toHtml (label <> "...")
H.button ! A.class_ "roux-timer-reset" $ "\8634"
H.button ! A.class_ "roux-timer-close" $ "\10005"
renderStepItem (StepRecipeRef ref) =
H.a ! A.href (H.toValue ("/recipes/" <> urlEncode (refPath ref <> ".cook"))) $
H.toHtml (refPath ref)