Tea Guide - Day 17
Data-Driven UI + Web AudioThe tea guide uses a data-driven approach where all tea information is stored in a structured JavaScript array. This allows easy maintenance, filtering, and rendering without touching HTML.
const teaDatabase = [
{
name: "Sencha",
category: "green", // For filtering
origin: "Japan",
icon: "🍵", // Unicode emoji
description: "The most popular Japanese...",
// Brewing parameters
temperature: {
celsius: 70,
fahrenheit: 158
},
steepTime: 60, // In seconds
amount: "1 tsp / 180ml",
// Multiple infusion times
infusions: ["1st: 60s", "2nd: 30s", "3rd: 45s"],
// Pro tip for this tea
tips: "Never use boiling water..."
},
// ... more teas
];
// Categories used for filtering and styling
const categories = [
"green", // Japanese & Chinese greens
"black", // Assam, Ceylon, etc.
"white", // Silver Needle, White Peony
"oolong", // Partially oxidized
"puerh", // Fermented teas
"herbal", // Tisanes (caffeine-free)
"blend" // Earl Grey, Chai, etc.
];
// CSS classes map directly to categories
// .cat-green, .cat-black, .cat-white, etc.
Storing temperature in both Celsius and Fahrenheit avoids runtime conversion and makes the UI code simpler. Trade-off: slightly more data, but better performance and clarity.
Cards are generated dynamically from the data model using template literals. This approach keeps the HTML clean and makes updates trivial - just modify the data.
function renderTeas() {
teaGrid.innerHTML = ''; // Clear existing cards
teaDatabase.forEach((tea, index) => {
// Apply filters
const matchesFilter =
currentFilter === 'all' ||
tea.category === currentFilter;
const matchesSearch =
tea.name.toLowerCase().includes(searchQuery) ||
tea.description.toLowerCase().includes(searchQuery) ||
tea.origin.toLowerCase().includes(searchQuery);
if (!matchesFilter || !matchesSearch) return;
// Create card element
const card = document.createElement('div');
card.className = 'tea-card';
card.dataset.category = tea.category;
// Generate HTML using template literal
card.innerHTML = `
<div class="tea-header" onclick="toggleCard(${index})">
<div class="tea-icon">${tea.icon}</div>
<h3 class="tea-name">${tea.name}</h3>
<p class="tea-origin">${tea.origin}</p>
<span class="tea-category cat-${tea.category}">
${capitalize(tea.category)}
</span>
</div>
<div class="tea-details" id="details-${index}">
...
</div>
`;
teaGrid.appendChild(card);
});
}
// Map infusion array to badge HTML
const infusionBadges = tea.infusions
.map(inf => `<span class="infusion-badge">${inf}</span>`)
.join('');
// Insert into template
`<div class="infusion-list">
${infusionBadges}
</div>`
Rendering Pipeline
The app combines text search with category filters. Both trigger a re-render, creating a reactive-like experience without a framework.
// State variables
let currentFilter = 'all';
let searchQuery = '';
// Search input handler with debounce-like behavior
searchInput.addEventListener('input', (e) => {
searchQuery = e.target.value.toLowerCase();
renderTeas(); // Re-render with new query
});
// Multi-field search
const matchesSearch =
tea.name.toLowerCase().includes(searchQuery) ||
tea.description.toLowerCase().includes(searchQuery) ||
tea.origin.toLowerCase().includes(searchQuery);
// Get all filter buttons
const filterButtons = document.querySelectorAll('.filter-btn');
filterButtons.forEach(btn => {
btn.addEventListener('click', () => {
// Update active state visually
filterButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update filter and re-render
currentFilter = btn.dataset.filter;
renderTeas();
});
});
// HTML with data attribute
// <button class="filter-btn" data-filter="green">Green</button>
// Both conditions must be true to show a card
const matchesFilter =
currentFilter === 'all' || tea.category === currentFilter;
const matchesSearch = /* ... multi-field search ... */;
// Skip rendering if either fails
if (!matchesFilter || !matchesSearch) return;
For 30 items, full re-render is fast. For thousands of items, consider virtual scrolling or incremental DOM updates.
Each card has a collapsible details section. Clicking the header toggles visibility with CSS transitions for smooth animation.
function toggleCard(index) {
const details = document.getElementById(`details-${index}`);
const header = details.previousElementSibling;
// Accordion behavior: close others first
document.querySelectorAll('.tea-details.expanded').forEach(el => {
if (el.id !== `details-${index}`) {
el.classList.remove('expanded');
el.previousElementSibling.classList.remove('expanded');
}
});
// Toggle current card
details.classList.toggle('expanded');
header.classList.toggle('expanded');
}
/* Hidden by default */
.tea-details {
padding: 0 20px 20px;
display: none;
}
/* Expanded state with animation */
.tea-details.expanded {
display: block;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Rotate arrow indicator */
.expand-indicator {
transition: transform 0.3s;
}
.tea-header.expanded .expand-indicator {
transform: translateY(-50%) rotate(180deg);
}
Accordion State Flow
The brewing timer uses setInterval for countdown and Web Audio API for a notification sound when complete - no audio files needed.
// Timer state
let timerInterval = null;
let timerSeconds = 0;
let timerRunning = false;
// Open timer modal with tea's steep time
function openTimer(teaName, seconds) {
event.stopPropagation(); // Don't toggle card
timerTeaName.textContent = teaName;
timerSeconds = seconds;
timerDisplay.textContent = formatTimerDisplay(seconds);
timerModal.classList.add('active');
// Reset state
timerRunning = false;
timerStart.textContent = 'Start';
}
timerStart.addEventListener('click', () => {
if (!timerRunning) {
// Start the timer
timerRunning = true;
timerStart.textContent = 'Pause';
timerInterval = setInterval(() => {
timerSeconds--;
timerDisplay.textContent = formatTimerDisplay(timerSeconds);
if (timerSeconds <= 0) {
clearInterval(timerInterval);
timerRunning = false;
timerDisplay.textContent = "Done!";
playNotificationSound();
}
}, 1000);
} else {
// Pause
timerRunning = false;
timerStart.textContent = 'Resume';
clearInterval(timerInterval);
}
});
function playNotificationSound() {
try {
// Create audio context
const ctx = new (window.AudioContext ||
window.webkitAudioContext)();
// Create oscillator (sound source)
const osc = ctx.createOscillator();
osc.type = 'sine'; // Sine wave = pure tone
osc.frequency.value = 800; // 800 Hz = pleasant beep
// Connect to speakers
osc.connect(ctx.destination);
// Play for 200ms
osc.start();
setTimeout(() => osc.stop(), 200);
} catch(e) {
// Audio not supported or blocked
console.log('Audio notification failed');
}
}
// Format for display (mm:ss)
function formatTimerDisplay(seconds) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// Format for cards (human readable)
function formatTime(seconds) {
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
}
Web Audio requires user interaction before playing. Our timer works because it's triggered by a button click, satisfying the autoplay policy.
The tea guide uses a consistent design system with semantic color coding for tea categories, glassmorphism effects, and responsive layouts.
/* Each tea category has a gradient badge */
.cat-green {
background: linear-gradient(135deg, #22c55e, #16a34a);
}
.cat-black {
background: linear-gradient(135deg, #78350f, #451a03);
color: #fbbf24; /* Gold text on dark bg */
}
.cat-white {
background: linear-gradient(135deg, #f5f5f4, #d6d3d1);
color: #44403c; /* Dark text on light bg */
}
.cat-oolong {
background: linear-gradient(135deg, #ca8a04, #a16207);
}
.cat-puerh {
background: linear-gradient(135deg, #7c2d12, #431407);
}
.cat-herbal {
background: linear-gradient(135deg, #c026d3, #a21caf);
}
.cat-blend {
background: linear-gradient(135deg, #0ea5e9, #0284c7);
}
.tea-card {
/* Semi-transparent background */
background: rgba(255,255,255,0.08);
/* Subtle border */
border: 1px solid rgba(244,228,188,0.2);
/* Frosted glass effect */
backdrop-filter: blur(10px);
/* Smooth corners */
border-radius: 16px;
/* Hover lift effect */
transition: transform 0.3s, box-shadow 0.3s;
}
.tea-card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 40px rgba(0,0,0,0.3);
}
.tea-grid {
display: grid;
/* Auto-fit columns, min 320px, max 1fr */
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 25px;
}
/* Brewing parameters - always 3 columns */
.brew-params {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
/* Mobile adjustments */
@media (max-width: 600px) {
.tea-grid {
grid-template-columns: 1fr;
}
.brew-params {
gap: 5px;
}
.param-value {
font-size: 0.8rem;
}
}
.timer-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
z-index: 1000;
/* Center content */
align-items: center;
justify-content: center;
}
.timer-modal.active {
display: flex; /* Show and center */
}
.timer-content {
background: linear-gradient(135deg, #2d5016, #1a3a0a);
border: 2px solid #f4e4bc;
border-radius: 20px;
padding: 40px;
max-width: 350px;
width: 90%; /* Responsive */
}
The green gradient background evokes tea leaves and nature. The warm cream color (#f4e4bc) for text and accents references parchment and aged tea packaging.