438 lines
12 KiB
JavaScript
438 lines
12 KiB
JavaScript
// Application data from provided JSON
|
|
const recipeData = {
|
|
"recipe": {
|
|
"name": "Barista-Recommended Pour-Over",
|
|
"coffee_weight": "21g",
|
|
"water_weight": "340g",
|
|
"ratio": "1:16",
|
|
"water_temp": "94-96°C",
|
|
"grind": "Medium-fine",
|
|
"total_time": "3:30-4:00"
|
|
},
|
|
"steps": [
|
|
{
|
|
"id": 1,
|
|
"name": "Preparation",
|
|
"description": "Set up your equipment and ingredients",
|
|
"details": [
|
|
"Grind 21g coffee to medium-fine consistency",
|
|
"Heat 340g filtered water to 94-96°C",
|
|
"Place metal filter in Chemex",
|
|
"Zero your scale with Chemex on it"
|
|
],
|
|
"timer": 0,
|
|
"water_amount": "0g",
|
|
"icon": "🔧"
|
|
},
|
|
{
|
|
"id": 2,
|
|
"name": "Bloom",
|
|
"description": "Pour water to bloom the coffee grounds",
|
|
"details": [
|
|
"Add ground coffee to filter",
|
|
"Pour 50g water slowly over grounds",
|
|
"Start from center, spiral outward",
|
|
"Wait for CO2 to release"
|
|
],
|
|
"timer": 45,
|
|
"water_amount": "50g",
|
|
"icon": "🌸"
|
|
},
|
|
{
|
|
"id": 3,
|
|
"name": "Second Pour",
|
|
"description": "Continue pouring to 200g total",
|
|
"details": [
|
|
"Pour slowly in circular motion",
|
|
"Avoid pouring near edges",
|
|
"Bring total weight to 200g",
|
|
"Maintain steady pour rate"
|
|
],
|
|
"timer": 30,
|
|
"water_amount": "200g total",
|
|
"icon": "💧"
|
|
},
|
|
{
|
|
"id": 4,
|
|
"name": "Final Pour",
|
|
"description": "Complete the pour to 340g total",
|
|
"details": [
|
|
"Continue circular pouring motion",
|
|
"Pour remaining water to 340g total",
|
|
"Keep consistent height and speed",
|
|
"Finish all water by 2:30-3:00"
|
|
],
|
|
"timer": 90,
|
|
"water_amount": "340g total",
|
|
"icon": "🫗"
|
|
},
|
|
{
|
|
"id": 5,
|
|
"name": "Finish",
|
|
"description": "Complete the brewing process",
|
|
"details": [
|
|
"Let coffee finish dripping",
|
|
"Remove filter when dripping stops",
|
|
"Give brew a gentle swirl",
|
|
"Serve immediately and enjoy!"
|
|
],
|
|
"timer": 60,
|
|
"water_amount": "Complete",
|
|
"icon": "☕"
|
|
}
|
|
]
|
|
};
|
|
|
|
// Application state
|
|
let currentStepIndex = 0;
|
|
let timerInterval = null;
|
|
let timeRemaining = 0;
|
|
let isTimerRunning = false;
|
|
let isTimerComplete = false;
|
|
|
|
// DOM elements
|
|
const welcomeScreen = document.getElementById('welcome-screen');
|
|
const brewingScreen = document.getElementById('brewing-screen');
|
|
const completionScreen = document.getElementById('completion-screen');
|
|
|
|
// Screen management
|
|
function showScreen(screenName) {
|
|
// Hide all screens
|
|
document.querySelectorAll('.screen').forEach(screen => {
|
|
screen.classList.remove('active');
|
|
});
|
|
|
|
// Show target screen
|
|
const targetScreen = document.getElementById(screenName + '-screen');
|
|
if (targetScreen) {
|
|
targetScreen.classList.add('active');
|
|
}
|
|
}
|
|
|
|
// Start brewing process
|
|
function startBrewing() {
|
|
currentStepIndex = 0;
|
|
showScreen('brewing');
|
|
loadCurrentStep();
|
|
}
|
|
|
|
// Load current step data
|
|
function loadCurrentStep() {
|
|
const step = recipeData.steps[currentStepIndex];
|
|
const stepNumber = currentStepIndex + 1;
|
|
const totalSteps = recipeData.steps.length;
|
|
|
|
// Update step indicator
|
|
document.getElementById('current-step').textContent = `Step ${stepNumber}`;
|
|
|
|
// Update progress bar
|
|
const progressPercentage = (stepNumber / totalSteps) * 100;
|
|
document.getElementById('progress-fill').style.width = `${progressPercentage}%`;
|
|
|
|
// Update step content
|
|
document.getElementById('step-icon').textContent = step.icon;
|
|
document.getElementById('step-title').textContent = step.name;
|
|
document.getElementById('step-description').textContent = step.description;
|
|
document.getElementById('water-amount').textContent = step.water_amount;
|
|
|
|
// Update step details
|
|
const detailsList = document.getElementById('step-details');
|
|
detailsList.innerHTML = '';
|
|
step.details.forEach(detail => {
|
|
const li = document.createElement('li');
|
|
li.textContent = detail;
|
|
detailsList.appendChild(li);
|
|
});
|
|
|
|
// Handle timer visibility and setup
|
|
const timerSection = document.getElementById('timer-section');
|
|
const nextStepBtn = document.getElementById('next-step-btn');
|
|
|
|
if (step.timer > 0) {
|
|
// Show timer for timed steps
|
|
timerSection.style.display = 'block';
|
|
timeRemaining = step.timer;
|
|
isTimerComplete = false;
|
|
updateTimerDisplay();
|
|
nextStepBtn.textContent = 'Next Step';
|
|
nextStepBtn.style.display = 'none'; // Hide until timer completes
|
|
|
|
// Reset timer controls
|
|
resetTimerControls();
|
|
} else {
|
|
// Hide timer for preparation step
|
|
timerSection.style.display = 'none';
|
|
nextStepBtn.textContent = 'Ready';
|
|
nextStepBtn.style.display = 'block';
|
|
}
|
|
|
|
// Update water indicator visibility
|
|
const waterIndicator = document.getElementById('water-indicator');
|
|
if (step.water_amount !== '0g') {
|
|
waterIndicator.style.display = 'flex';
|
|
} else {
|
|
waterIndicator.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Timer functionality
|
|
function updateTimerDisplay() {
|
|
const minutes = Math.floor(timeRemaining / 60);
|
|
const seconds = timeRemaining % 60;
|
|
const display = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
document.getElementById('timer-display').textContent = display;
|
|
}
|
|
|
|
function toggleTimer() {
|
|
if (isTimerRunning) {
|
|
pauseTimer();
|
|
} else {
|
|
startTimer();
|
|
}
|
|
}
|
|
|
|
function startTimer() {
|
|
if (timeRemaining <= 0 && !isTimerComplete) {
|
|
timeRemaining = recipeData.steps[currentStepIndex].timer;
|
|
}
|
|
|
|
isTimerRunning = true;
|
|
document.getElementById('play-pause-text').textContent = '⏸';
|
|
|
|
timerInterval = setInterval(() => {
|
|
timeRemaining--;
|
|
updateTimerDisplay();
|
|
|
|
if (timeRemaining <= 0) {
|
|
completeTimer();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function pauseTimer() {
|
|
isTimerRunning = false;
|
|
document.getElementById('play-pause-text').textContent = '▶';
|
|
clearInterval(timerInterval);
|
|
}
|
|
|
|
function resetTimer() {
|
|
pauseTimer();
|
|
timeRemaining = recipeData.steps[currentStepIndex].timer;
|
|
isTimerComplete = false;
|
|
updateTimerDisplay();
|
|
resetTimerControls();
|
|
|
|
// Hide next step button until timer completes again
|
|
const nextStepBtn = document.getElementById('next-step-btn');
|
|
nextStepBtn.style.display = 'none';
|
|
}
|
|
|
|
function completeTimer() {
|
|
pauseTimer();
|
|
isTimerComplete = true;
|
|
|
|
// Visual feedback
|
|
const timerDisplay = document.getElementById('timer-display');
|
|
timerDisplay.classList.add('timer-complete');
|
|
|
|
// Audio notification
|
|
playNotificationSound();
|
|
|
|
// Show next step button
|
|
const nextStepBtn = document.getElementById('next-step-btn');
|
|
nextStepBtn.style.display = 'block';
|
|
nextStepBtn.textContent = 'Next Step';
|
|
|
|
// Remove visual feedback after animation
|
|
setTimeout(() => {
|
|
timerDisplay.classList.remove('timer-complete');
|
|
}, 3000);
|
|
|
|
// Auto-advance after a short delay
|
|
setTimeout(() => {
|
|
if (currentStepIndex < recipeData.steps.length - 1) {
|
|
nextStep();
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
function resetTimerControls() {
|
|
document.getElementById('play-pause-text').textContent = '▶';
|
|
const nextStepBtn = document.getElementById('next-step-btn');
|
|
if (recipeData.steps[currentStepIndex].timer > 0) {
|
|
nextStepBtn.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Audio notification
|
|
function playNotificationSound() {
|
|
// Create a simple beep sound using Web Audio API
|
|
try {
|
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
|
|
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
|
|
gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.1);
|
|
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + 0.3);
|
|
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.3);
|
|
} catch (error) {
|
|
// Fallback: show visual notification if audio fails
|
|
console.log('Timer complete!');
|
|
}
|
|
}
|
|
|
|
// Step navigation
|
|
function nextStep() {
|
|
// If current step has a timer and it's not complete, start the timer
|
|
const currentStep = recipeData.steps[currentStepIndex];
|
|
if (currentStep.timer > 0 && !isTimerComplete && !isTimerRunning) {
|
|
startTimer();
|
|
return;
|
|
}
|
|
|
|
// Move to next step
|
|
currentStepIndex++;
|
|
|
|
if (currentStepIndex >= recipeData.steps.length) {
|
|
// Brewing complete
|
|
showScreen('completion');
|
|
} else {
|
|
// Load next step
|
|
loadCurrentStep();
|
|
}
|
|
}
|
|
|
|
// Completion screen functions
|
|
function restartBrewing() {
|
|
currentStepIndex = 0;
|
|
pauseTimer();
|
|
timeRemaining = 0;
|
|
isTimerComplete = false;
|
|
showScreen('brewing');
|
|
loadCurrentStep();
|
|
}
|
|
|
|
function goToWelcome() {
|
|
currentStepIndex = 0;
|
|
pauseTimer();
|
|
timeRemaining = 0;
|
|
isTimerComplete = false;
|
|
showScreen('welcome');
|
|
}
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.code === 'Space') {
|
|
event.preventDefault();
|
|
const currentScreen = document.querySelector('.screen.active');
|
|
|
|
if (currentScreen && currentScreen.id === 'brewing-screen') {
|
|
const currentStep = recipeData.steps[currentStepIndex];
|
|
if (currentStep.timer > 0) {
|
|
toggleTimer();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (event.key === 'Enter') {
|
|
const currentScreen = document.querySelector('.screen.active');
|
|
|
|
if (currentScreen && currentScreen.id === 'welcome-screen') {
|
|
startBrewing();
|
|
} else if (currentScreen && currentScreen.id === 'brewing-screen') {
|
|
nextStep();
|
|
}
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
const currentScreen = document.querySelector('.screen.active');
|
|
|
|
if (currentScreen && currentScreen.id !== 'welcome-screen') {
|
|
goToWelcome();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Initialize app
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Show welcome screen by default
|
|
showScreen('welcome');
|
|
|
|
// Add some helpful console instructions
|
|
console.log('☕ Pour-Over Coffee Timer');
|
|
console.log('Keyboard shortcuts:');
|
|
console.log('- Space: Start/pause timer');
|
|
console.log('- Enter: Start brewing or next step');
|
|
console.log('- Escape: Return to welcome screen');
|
|
});
|
|
|
|
// Prevent accidental page refresh during brewing
|
|
window.addEventListener('beforeunload', (event) => {
|
|
const currentScreen = document.querySelector('.screen.active');
|
|
if (currentScreen && currentScreen.id === 'brewing-screen') {
|
|
event.preventDefault();
|
|
event.returnValue = 'Are you sure you want to leave? Your brewing progress will be lost.';
|
|
return event.returnValue;
|
|
}
|
|
});
|
|
|
|
// Handle visibility change (when user switches tabs)
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden && isTimerRunning) {
|
|
// Optionally pause timer when tab is not visible
|
|
// pauseTimer();
|
|
}
|
|
});
|
|
|
|
// Responsive handling for timer display
|
|
function updateTimerSize() {
|
|
const timerDisplay = document.getElementById('timer-display');
|
|
const containerWidth = timerDisplay.parentElement.offsetWidth;
|
|
|
|
if (containerWidth < 400) {
|
|
timerDisplay.style.fontSize = '3rem';
|
|
} else {
|
|
timerDisplay.style.fontSize = '4rem';
|
|
}
|
|
}
|
|
|
|
// Call on resize
|
|
window.addEventListener('resize', updateTimerSize);
|
|
|
|
// Accessibility enhancements
|
|
function announceStepChange() {
|
|
const step = recipeData.steps[currentStepIndex];
|
|
const announcement = `Step ${currentStepIndex + 1}: ${step.name}. ${step.description}`;
|
|
|
|
// Create and use aria-live region for screen reader announcements
|
|
let liveRegion = document.getElementById('aria-live-region');
|
|
if (!liveRegion) {
|
|
liveRegion = document.createElement('div');
|
|
liveRegion.id = 'aria-live-region';
|
|
liveRegion.setAttribute('aria-live', 'polite');
|
|
liveRegion.setAttribute('aria-atomic', 'true');
|
|
liveRegion.style.position = 'absolute';
|
|
liveRegion.style.left = '-10000px';
|
|
liveRegion.style.width = '1px';
|
|
liveRegion.style.height = '1px';
|
|
liveRegion.style.overflow = 'hidden';
|
|
document.body.appendChild(liveRegion);
|
|
}
|
|
|
|
liveRegion.textContent = announcement;
|
|
}
|
|
|
|
// Export functions for global access (for onclick handlers)
|
|
window.startBrewing = startBrewing;
|
|
window.nextStep = nextStep;
|
|
window.toggleTimer = toggleTimer;
|
|
window.resetTimer = resetTimer;
|
|
window.restartBrewing = restartBrewing;
|
|
window.goToWelcome = goToWelcome; |