(function() {
'use strict';
const container = document.querySelector('[data-recipe-path]');
if (!container) return;
const STORAGE_KEY = 'cook.timers.' + container.getAttribute('data-recipe-path');
let timers = loadTimers();
const lastStatus = new Map();
let tickInterval = null;
let audioCtx = null;
let notifyPermissionRequested = false;
function loadTimers() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return new Map();
return new Map(Object.entries(JSON.parse(raw)));
} catch (e) {
return new Map();
}
}
function saveTimers() {
try {
if (timers.size === 0) {
localStorage.removeItem(STORAGE_KEY);
return;
}
const obj = {};
timers.forEach(function(entry, key) { obj[key] = entry; });
localStorage.setItem(STORAGE_KEY, JSON.stringify(obj));
} catch (e) {
}
}
function remainingSeconds(entry) {
return entry.duration - (Date.now() - entry.startedAt) / 1000;
}
function statusOf(entry) {
return remainingSeconds(entry) <= 0 ? 'done' : 'running';
}
function formatTime(totalSeconds) {
const s = Math.max(0, Math.round(totalSeconds));
const m = Math.floor(s / 60);
const sec = s % 60;
return m + ':' + (sec < 10 ? '0' : '') + sec;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function getAudioContext() {
if (!audioCtx) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return null;
audioCtx = new Ctx();
}
return audioCtx;
}
function playChime() {
try {
const ctx = getAudioContext();
if (!ctx) return;
if (ctx.state === 'suspended') ctx.resume();
[0, 0.25, 0.5].forEach(function(offset) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(0.0001, ctx.currentTime + offset);
gain.gain.exponentialRampToValueAtTime(0.2, ctx.currentTime + offset + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + offset + 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime + offset);
osc.stop(ctx.currentTime + offset + 0.16);
});
} catch (e) {
}
}
function requestNotifyPermissionOnce() {
if (notifyPermissionRequested) return;
notifyPermissionRequested = true;
try {
if (window.Notification && Notification.permission === 'default') {
Notification.requestPermission();
}
} catch (e) {
}
}
function notifyDone(entry) {
try {
if (!window.Notification || Notification.permission !== 'granted') return;
let body = entry.display;
if (entry.section) {
body += ' — ' + entry.section + ', Step ' + entry.step;
} else if (entry.step) {
body += ' — Step ' + entry.step;
}
new Notification('Timer done', { body: body });
} catch (e) {
}
}
function badgesForKey(key) {
return document.querySelectorAll('.cooking-overlay [data-timer-key="' + key + '"]');
}
function updateBadgeDOM(key, entry) {
const status = statusOf(entry);
const label = '⏱️ ' + formatTime(remainingSeconds(entry));
badgesForKey(key).forEach(function(badge) {
badge.classList.toggle('timer-running', status === 'running');
badge.classList.toggle('timer-done', status === 'done');
badge.textContent = label;
});
}
function resetBadgeDOM(key) {
badgesForKey(key).forEach(function(badge) {
badge.classList.remove('timer-running', 'timer-done');
badge.textContent = '⏱️ ' + (badge.getAttribute('data-display') || '');
});
}
function renderStrip() {
const strip = document.getElementById('cooking-timer-strip');
if (!strip) return;
if (timers.size === 0) {
strip.classList.add('hidden');
strip.classList.remove('flex');
strip.innerHTML = '';
return;
}
strip.classList.remove('hidden');
strip.classList.add('flex');
const html = [];
timers.forEach(function(entry, key) {
const status = statusOf(entry);
const cls = 'timer-chip' + (status === 'done' ? ' timer-done' : '');
html.push(
'<button type="button" class="' + cls + '" data-timer-chip="' + key + '" ' +
'aria-label="' + escapeHtml(entry.display) + '">⏱ ' +
formatTime(remainingSeconds(entry)) + '</button>'
);
});
strip.innerHTML = html.join('');
}
function updateModalIfOpen() {
const modal = document.getElementById('timer-detail-modal');
if (!modal || modal.classList.contains('hidden')) return;
const key = modal.getAttribute('data-active-key');
const entry = timers.get(key);
if (!entry) {
closeModal();
return;
}
const timeEl = modal.querySelector('#timer-modal-time');
timeEl.textContent = formatTime(remainingSeconds(entry));
timeEl.classList.toggle('timer-done', statusOf(entry) === 'done');
}
function refreshDOM() {
timers.forEach(function(entry, key) { updateBadgeDOM(key, entry); });
renderStrip();
updateModalIfOpen();
}
function tick() {
timers.forEach(function(entry, key) {
const status = statusOf(entry);
if (status === 'done' && lastStatus.get(key) === 'running') {
playChime();
notifyDone(entry);
}
lastStatus.set(key, status);
});
refreshDOM();
}
function ensureTicking() {
if (tickInterval) return;
tickInterval = setInterval(tick, 1000);
}
function stopTickingIfIdle() {
if (timers.size === 0 && tickInterval) {
clearInterval(tickInterval);
tickInterval = null;
}
}
function startTimer(badge) {
const key = badge.getAttribute('data-timer-key');
if (timers.has(key)) {
openDetail(key);
return;
}
const duration = parseInt(badge.getAttribute('data-seconds'), 10);
if (!duration || duration <= 0) return;
requestNotifyPermissionOnce();
getAudioContext();
const entry = {
startedAt: Date.now(),
duration: duration,
display: badge.getAttribute('data-display') || '',
section: badge.getAttribute('data-section') || '',
step: badge.getAttribute('data-step') || ''
};
timers.set(key, entry);
lastStatus.set(key, 'running');
saveTimers();
updateBadgeDOM(key, entry);
renderStrip();
ensureTicking();
}
function cancelTimer(key) {
timers.delete(key);
lastStatus.delete(key);
saveTimers();
resetBadgeDOM(key);
renderStrip();
stopTickingIfIdle();
}
function ensureModal() {
const overlay = document.getElementById('cooking-overlay');
if (!overlay) return null;
let modal = document.getElementById('timer-detail-modal');
if (modal) return modal;
modal = document.createElement('div');
modal.id = 'timer-detail-modal';
modal.className = 'fixed inset-0 z-50 hidden items-center justify-center bg-black/50';
modal.innerHTML =
'<div class="card shadow-[var(--shadow-overlay)] max-w-sm w-full mx-4 p-6 text-center">' +
'<div class="timer-modal-time mb-2" id="timer-modal-time"></div>' +
'<div class="text-muted mb-1" id="timer-modal-context"></div>' +
'<div class="text-faint text-sm mb-6" id="timer-modal-original"></div>' +
'<div class="flex justify-center gap-2">' +
'<button type="button" class="btn" id="timer-modal-close">Close</button>' +
'<button type="button" class="btn btn-danger" id="timer-modal-cancel">Cancel timer</button>' +
'</div>' +
'</div>';
overlay.appendChild(modal);
modal.addEventListener('click', function(e) {
if (e.target === modal) closeModal();
});
modal.querySelector('#timer-modal-close').addEventListener('click', closeModal);
modal.querySelector('#timer-modal-cancel').addEventListener('click', function() {
const key = modal.getAttribute('data-active-key');
closeModal();
if (key) cancelTimer(key);
});
return modal;
}
function closeModal() {
const modal = document.getElementById('timer-detail-modal');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
}
function openDetail(key) {
const entry = timers.get(key);
if (!entry) return;
const modal = ensureModal();
if (!modal) return;
modal.setAttribute('data-active-key', key);
let context = entry.section || '';
if (entry.step) context += (context ? ' — ' : '') + 'Step ' + entry.step;
modal.querySelector('#timer-modal-context').textContent = context;
modal.querySelector('#timer-modal-original').textContent = 'Originally ' + entry.display;
modal.classList.remove('hidden');
modal.classList.add('flex');
updateModalIfOpen();
}
document.addEventListener('click', function(e) {
const chip = e.target.closest('[data-timer-chip]');
if (chip) {
openDetail(chip.getAttribute('data-timer-chip'));
return;
}
const badge = e.target.closest('[data-timer]');
if (badge && badge.closest('.cooking-overlay')) {
startTimer(badge);
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const modal = document.getElementById('timer-detail-modal');
if (modal && !modal.classList.contains('hidden')) {
closeModal();
e.stopImmediatePropagation();
}
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
const badge = e.target.closest && e.target.closest('[data-timer]');
if (badge && badge.closest('.cooking-overlay')) {
e.preventDefault();
startTimer(badge);
}
});
window.addEventListener('storage', function(e) {
if (e.key !== STORAGE_KEY) return;
timers = loadTimers();
timers.forEach(function(entry, key) { lastStatus.set(key, statusOf(entry)); });
refreshDOM();
if (timers.size > 0) {
ensureTicking();
} else {
stopTickingIfIdle();
}
});
document.addEventListener('cookingmode:opened', refreshDOM);
timers.forEach(function(entry, key) { lastStatus.set(key, statusOf(entry)); });
if (timers.size > 0) ensureTicking();
})();