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 = `
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();