const DEFAULT_PROBE_NAMES = ['Probe 1', 'Probe 2', 'Probe 3', 'Probe 4']; const PRESETS = [ { label: 'Brisket', temp: 203 }, { label: 'Pork Butt', temp: 205 }, { label: 'Poultry (breast)', temp: 165 }, { label: 'Poultry (thigh)', temp: 175 }, { label: 'Ribs', temp: 203 }, ]; let activeSession = null; let viewedSession = null; let allSessions = []; let latestReading = null; let setpoints = []; let chartInstance = null; let cardsInitialized = false; let probeNames = [...DEFAULT_PROBE_NAMES]; async function api(path, options = {}) { const resp = await fetch(path, options); if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`); return resp.json(); } function formatTime(iso) { if (!iso) return '--'; const d = new Date(iso + (iso.endsWith('Z') ? '' : 'Z')); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } function statusClass(connected) { return connected ? 'ok' : 'danger'; } function createProbeCards() { const container = document.getElementById('probe-cards'); container.innerHTML = ''; for (let i = 0; i < 4; i++) { const card = document.createElement('div'); card.className = 'probe-card'; card.dataset.probe = i + 1; card.innerHTML = `
${DEFAULT_PROBE_NAMES[i]} #${i + 1}
No target set
---
`; container.appendChild(card); } // Wire up preset selectors and save buttons once. document.querySelectorAll('.preset-select').forEach(sel => { sel.addEventListener('change', () => { const probe = sel.dataset.probe; const customInput = document.querySelector(`.custom-target[data-probe="${probe}"]`); if (sel.value && sel.value !== 'custom') { customInput.value = sel.value; } }); }); document.querySelectorAll('.save-setpoint').forEach(btn => { btn.addEventListener('click', async () => { const probe = parseInt(btn.dataset.probe, 10); const target = parseFloat(document.querySelector(`.custom-target[data-probe="${probe}"]`).value); const label = document.querySelector(`.target-label[data-probe="${probe}"]`).value; if (isNaN(target)) return; await saveSetpoint(probe, label, target); }); }); // Click the probe name to rename it (writes through to the CYD). document.querySelectorAll('.probe-card .name').forEach(el => { el.addEventListener('click', () => beginRename(el)); }); cardsInitialized = true; } async function beginRename(el) { if (el.querySelector('input')) return; // already editing const probe = parseInt(el.dataset.probe, 10); const current = el.textContent; el.innerHTML = ''; const input = document.createElement('input'); input.type = 'text'; input.className = 'rename-input'; input.value = current; input.maxLength = 24; el.appendChild(input); input.focus(); input.select(); let done = false; const finish = async (commit) => { if (done) return; done = true; const newName = (commit ? input.value.trim() : current) || current; el.textContent = newName; if (commit && newName !== current) { await saveProbeName(probe, newName); } }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } if (e.key === 'Escape') { e.preventDefault(); input.value = current; input.blur(); } }); input.addEventListener('blur', () => finish(true)); } async function saveProbeName(probeNumber, name) { const next = [...probeNames]; next[probeNumber - 1] = name; try { const result = await api('/api/probes', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ names: next }), }); if (result.names) probeNames = result.names; await refreshDashboard(); // pick up the new name in the latest reading } catch (e) { console.error('Rename failed:', e); alert(`Failed to rename probe: ${e.message}`); } } async function loadProbeNames() { try { const result = await api('/api/probes'); if (result.names) probeNames = result.names; } catch (e) { console.warn('Probe names fetch failed, using defaults:', e); } } function updateProbeCards(reading, setpoints) { const probes = reading?.probes || []; const unit = reading?.unit || 'F'; for (let i = 0; i < 4; i++) { const card = document.querySelector(`.probe-card[data-probe="${i + 1}"]`); if (!card) continue; const probe = probes[i] || { id: i + 1, name: probeNames[i] || DEFAULT_PROBE_NAMES[i], temperature: null, connected: false }; const setpoint = setpoints.find(s => s.probe_number === (i + 1)); card.classList.toggle('connected', probe.connected); // Don't overwrite the name while the user is editing it. const nameEl = card.querySelector('.name'); if (!nameEl.querySelector('input')) { nameEl.textContent = probe.name || probeNames[i] || DEFAULT_PROBE_NAMES[i]; } const targetEl = card.querySelector('.target'); if (setpoint) { const labelText = setpoint.label ? setpoint.label : `Probe ${i + 1}`; targetEl.textContent = `${labelText}: ${setpoint.target_temp.toFixed(0)}°${unit}`; } else { targetEl.textContent = 'No target set'; } const tempEl = card.querySelector('.temp'); if (probe.connected) { tempEl.classList.remove('disconnected'); tempEl.innerHTML = `${probe.temperature.toFixed(1)}°${unit}`; } else { tempEl.classList.add('disconnected'); tempEl.textContent = '---'; } card.querySelector('.status').textContent = probe.connected ? 'Connected' : 'Disconnected'; // Only update setpoint placeholders, never overwrite values while typing. const customInput = card.querySelector('.custom-target'); const labelInput = card.querySelector('.target-label'); const presetSelect = card.querySelector('.preset-select'); if (!customInput.matches(':focus') && !labelInput.matches(':focus')) { customInput.placeholder = `Target °${unit}`; if (!customInput.value && setpoint) { customInput.value = setpoint.target_temp; } if (!labelInput.value && setpoint) { labelInput.value = setpoint.label || ''; } // Update preset option labels with current unit. Array.from(presetSelect.options).forEach((opt, idx) => { if (idx > 0 && idx <= PRESETS.length) { const p = PRESETS[idx - 1]; opt.textContent = `${p.label} (${p.temp}°${unit})`; } }); } } } async function saveSetpoint(probeNumber, label, targetTemp) { if (!viewedSession) return; await api(`/api/setpoints/${viewedSession.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ probe_number: probeNumber, label, target_temp: targetTemp }), }); await loadSetpoints(); // Do not call refreshDashboard here; it would clear the input the user just used. // The next periodic poll will pick up the new setpoint from /api/status. } function renderStatusBar(reading) { const cydEl = document.getElementById('cyd-status'); const battEl = document.getElementById('battery-status'); const sigEl = document.getElementById('signal-status'); if (!reading) { cydEl.textContent = 'CYD unreachable'; cydEl.className = 'danger'; return; } const connected = reading.connected; cydEl.textContent = connected ? 'CYD connected' : 'CYD: BLE disconnected'; cydEl.className = connected ? 'ok' : 'warn'; battEl.textContent = `Battery: ${reading.battery ?? '--'}%`; const rssi = reading.rssi; sigEl.textContent = rssi !== undefined ? `Signal: ${rssi} dBm` : 'Signal: WiFi only'; } async function loadSessions() { allSessions = await api('/api/sessions'); activeSession = allSessions.find(s => !s.ended_at) || null; // Default the viewed session to the active one, or the most recent past one. if (!viewedSession || !allSessions.find(s => s.id === viewedSession.id)) { viewedSession = activeSession || allSessions[0] || null; } const activeEl = document.getElementById('active-session'); const endBtn = document.getElementById('end-session-btn'); if (activeSession) { activeEl.innerHTML = ` ${activeSession.name} started at ${formatTime(activeSession.started_at)}
${activeSession.notes || 'No notes'} `; endBtn.disabled = false; } else { activeEl.textContent = 'No active session. Start one above to begin recording.'; endBtn.disabled = true; } renderSessionList(); } function renderSessionList() { const listEl = document.getElementById('past-sessions'); if (!listEl) return; const past = allSessions.filter(s => s.ended_at); if (past.length === 0) { listEl.innerHTML = '

No past sessions yet.

'; return; } listEl.innerHTML = past.map(s => { const isViewed = viewedSession && viewedSession.id === s.id; const started = formatTime(s.started_at); const ended = formatTime(s.ended_at); return ` `; }).join(''); listEl.querySelectorAll('.session-row').forEach(btn => { btn.addEventListener('click', () => { const id = parseInt(btn.dataset.id, 10); viewedSession = allSessions.find(s => s.id === id) || null; renderSessionList(); loadSetpoints(); refreshChart(); updateChartHeader(); }); }); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])); } function updateChartHeader() { const headerEl = document.getElementById('chart-session-label'); if (!headerEl) return; if (!viewedSession) { headerEl.textContent = 'No session'; return; } if (activeSession && viewedSession.id === activeSession.id) { headerEl.textContent = `Live — ${viewedSession.name}`; } else { headerEl.textContent = `Viewing past — ${viewedSession.name}`; } } async function loadSetpoints() { if (!viewedSession) { setpoints = []; updateChartHeader(); return; } setpoints = await api(`/api/setpoints/${viewedSession.id}`); updateChartHeader(); } async function refreshDashboard() { try { const status = await api('/api/status'); latestReading = status.latest_reading; if (!cardsInitialized) { createProbeCards(); } updateProbeCards(status.latest_reading, setpoints); renderStatusBar(status.latest_reading); document.getElementById('last-updated').textContent = `Last updated: ${formatTime(new Date().toISOString())}`; } catch (e) { console.error('Refresh failed:', e); renderStatusBar(null); } } async function refreshChart() { if (!chartInstance) return; if (!viewedSession) { chartInstance.data.labels = []; chartInstance.data.datasets = []; chartInstance.update(); return; } const range = parseInt(document.getElementById('chart-range').value, 10); const data = await api(`/api/readings/${viewedSession.id}?minutes=${range}`); const labels = data.map(r => formatTime(r.received_at)); const datasets = [1, 2, 3, 4].map((num, idx) => ({ label: probeNames[idx] || (latestReading?.probes?.[idx]?.name) || `Probe ${num}`, data: data.map(r => r[`probe${num}_temp`]), borderColor: ['#ff6b35', '#4caf50', '#2196f3', '#9c27b0'][idx], backgroundColor: ['rgba(255,107,53,0.1)', 'rgba(76,175,80,0.1)', 'rgba(33,150,243,0.1)', 'rgba(156,39,176,0.1)'][idx], fill: true, tension: 0.3, spanGaps: true, })); chartInstance.data.labels = labels; chartInstance.data.datasets = datasets; chartInstance.update(); } function initChart() { const ctx = document.getElementById('temp-chart').getContext('2d'); chartInstance = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { title: { display: true, text: 'Temperature' }, grid: { color: '#333' }, ticks: { color: '#aaa' }, }, x: { grid: { color: '#333' }, ticks: { color: '#aaa', maxTicksLimit: 8 }, }, }, plugins: { legend: { labels: { color: '#f0f0f0' } }, }, }, }); } document.getElementById('new-session-form').addEventListener('submit', async (e) => { e.preventDefault(); const name = document.getElementById('session-name').value; const notes = document.getElementById('session-notes').value; const result = await api('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, notes }), }); document.getElementById('session-name').value = ''; document.getElementById('session-notes').value = ''; await loadSessions(); // Switch the chart to the newly created active session. viewedSession = allSessions.find(s => s.id === result.session_id) || activeSession; await loadSetpoints(); await refreshChart(); renderSessionList(); }); document.getElementById('end-session-btn').addEventListener('click', async () => { if (!activeSession) return; await api(`/api/sessions/${activeSession.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ end: true }), }); await loadSessions(); // If we were viewing the just-ended session, keep viewing it (now a past session). await loadSetpoints(); await refreshChart(); }); document.getElementById('refresh-chart-btn').addEventListener('click', refreshChart); document.getElementById('chart-range').addEventListener('change', refreshChart); async function init() { initChart(); await loadProbeNames(); await loadSessions(); await loadSetpoints(); // First call creates the cards; subsequent calls only update values. await refreshDashboard(); await refreshChart(); setInterval(refreshDashboard, 5000); setInterval(() => { if (viewedSession) refreshChart(); }, 30000); } init();