Tech Details

Tea Guide - Day 17

Data-Driven UI + Web Audio

Table of Contents

01. Data Model Architecture

The 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.

Tea Data Structure

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 ];

Category System

// 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.
Design Pattern

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.

02. Dynamic Card Rendering

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.

Render Function

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); }); }

Infusion Badges Generation

// 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

Data Array -> Filter -> Map to HTML -> DOM Insert

03. Search & Filter System

The app combines text search with category filters. Both trigger a re-render, creating a reactive-like experience without a framework.

Search Implementation

// 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);

Filter Button Handler

// 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>

Combined Filter Logic

// 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;
Performance Note

For 30 items, full re-render is fast. For thousands of items, consider virtual scrolling or incremental DOM updates.

04. Expandable Card Pattern

Each card has a collapsible details section. Clicking the header toggles visibility with CSS transitions for smooth animation.

Toggle Function

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'); }

CSS Animation

/* 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

Click Header -> Close Others -> Toggle Current -> Animate

05. Timer with Web Audio

The brewing timer uses setInterval for countdown and Web Audio API for a notification sound when complete - no audio files needed.

Timer State & Controls

// 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'; }

Countdown Logic

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); } });

Web Audio Notification

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'); } }

Time Formatting

// 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`; }
Browser Autoplay Policy

Web Audio requires user interaction before playing. Our timer works because it's triggered by a button click, satisfying the autoplay policy.

06. CSS Design System

The tea guide uses a consistent design system with semantic color coding for tea categories, glassmorphism effects, and responsive layouts.

Category Color System

/* 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); }

Glassmorphism Cards

.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); }

Responsive Grid

.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; } }

Modal Overlay Pattern

.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 */ }
Design Philosophy

The green gradient background evokes tea leaves and nature. The warm cream color (#f4e4bc) for text and accents references parchment and aged tea packaging.