From 5abf5490b472df7e7ed59cfa8fee5fe8433bf245 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Wed, 20 May 2026 22:50:35 -0400 Subject: [PATCH] feat: add interactive countdown timers to recipe steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/Roux/Html.hs | 120 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 6 deletions(-) diff --git a/src/Roux/Html.hs b/src/Roux/Html.hs index d7a9dcc..14d2133 100644 --- a/src/Roux/Html.hs +++ b/src/Roux/Html.hs @@ -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)