const state = { range: "24h", bucket: "5m", tz: "local", rangeStart: null, rangeEnd: null, charts: {}, timer: null, }; const colors = { obs: "#7bdff2", forecast: "#f4b942", gust: "#ff7d6b", humidity: "#7ee081", uvi: "#f4d35e", light: "#b8f2e6", precip: "#4ea8de", }; function formatNumber(value, digits) { if (value === null || value === undefined) { return "--"; } return Number(value).toFixed(digits); } function formatDateTime(value) { if (!value) return "--"; const dt = value instanceof Date ? value : new Date(value); if (Number.isNaN(dt.getTime())) return "--"; const opts = { dateStyle: "medium", timeStyle: "short" }; if (state.tz === "utc") { return new Intl.DateTimeFormat(undefined, { ...opts, timeZone: "UTC" }).format(dt); } return new Intl.DateTimeFormat(undefined, opts).format(dt); } function formatTick(value) { const dt = new Date(value); if (Number.isNaN(dt.getTime())) return ""; const opts = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }; if (state.tz === "utc") { return new Intl.DateTimeFormat(undefined, { ...opts, timeZone: "UTC" }).format(dt); } return new Intl.DateTimeFormat(undefined, opts).format(dt); } function series(points, key) { return points.map((p) => ({ x: p.ts, y: p[key] === undefined ? null : p[key], })); } function filterRange(points, start, end) { if (!start || !end) return points; const min = start.getTime(); const max = end.getTime(); return points.filter((p) => { if (!p.ts) return false; const t = new Date(p.ts).getTime(); return !Number.isNaN(t) && t >= min && t <= max; }); } function safeDate(value) { if (!value) return null; const dt = value instanceof Date ? value : new Date(value); return Number.isNaN(dt.getTime()) ? null : dt; } function startOfDay(date, tz) { if (tz === "utc") { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); } return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function startOfWeekSunday(date, tz) { const day = tz === "utc" ? date.getUTCDay() : date.getDay(); const base = startOfDay(date, tz); const diff = day; return new Date(base.getTime() - diff * 24 * 60 * 60 * 1000); } function computeRange(range, tz) { const now = new Date(); let start = null; let end = null; let axisStart = null; let axisEnd = null; switch (range) { case "6h": { end = now; start = new Date(now.getTime() - 6 * 60 * 60 * 1000); axisStart = start; axisEnd = end; break; } case "24h": { start = startOfDay(now, tz); end = new Date(start.getTime() + 24 * 60 * 60 * 1000); axisStart = start; axisEnd = end; break; } case "72h": { end = startOfDay(now, tz); start = new Date(end.getTime() - 72 * 60 * 60 * 1000); axisStart = start; axisEnd = end; break; } case "7d": { start = startOfWeekSunday(now, tz); end = new Date(start.getTime() + 7 * 24 * 60 * 60 * 1000); axisStart = start; axisEnd = end; break; } default: { end = now; start = new Date(now.getTime() - 24 * 60 * 60 * 1000); axisStart = start; axisEnd = end; } } return { start, end, axisStart, axisEnd }; } function minMax(values) { let min = null; let max = null; for (const v of values) { if (v === null || v === undefined) continue; if (min === null || v < min) min = v; if (max === null || v > max) max = v; } return { min, max }; } function sum(values) { let total = 0; let seen = false; for (const v of values) { if (v === null || v === undefined) continue; total += v; seen = true; } return seen ? total : null; } function updateText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; } function upsertChart(id, config) { const ctx = document.getElementById(id); if (!ctx) return; if (state.charts[id]) { state.charts[id].data = config.data; state.charts[id].options = config.options; state.charts[id].update(); return; } state.charts[id] = new Chart(ctx, config); } function baseOptions(range) { return { responsive: true, maintainAspectRatio: false, interaction: { mode: "index", intersect: false }, plugins: { legend: { labels: { color: "#d6f0f0" } }, tooltip: { mode: "index", intersect: false, callbacks: { title: (items) => { if (!items.length) return ""; return formatDateTime(new Date(items[0].parsed.x)); }, }, }, }, scales: { x: { type: "time", time: { unit: "hour" }, ticks: { color: "#a4c4c4", maxTicksLimit: 6, callback: (value) => formatTick(value), }, min: range && range.axisStart ? range.axisStart : undefined, max: range && range.axisEnd ? range.axisEnd : undefined, grid: { color: "rgba(123, 223, 242, 0.08)" }, }, y: { ticks: { color: "#a4c4c4" }, grid: { color: "rgba(123, 223, 242, 0.08)" }, }, }, elements: { line: { tension: 0.2, borderWidth: 2 }, point: { radius: 0, hitRadius: 8 }, }, spanGaps: true, }; } function renderDashboard(data) { const latest = data.latest; const tzLabel = state.tz === "utc" ? "UTC" : "Local"; updateText("site-meta", `${data.site} | model ${data.model} | ${tzLabel}`); updateText("last-updated", `updated ${formatDateTime(data.generated_at)}`); const forecastMeta = data.forecast && data.forecast.points && data.forecast.points.length ? `forecast retrieved ${formatDateTime(data.forecast.retrieved_at)}` : "forecast not available"; updateText("forecast-meta", forecastMeta); if (latest) { updateText("live-temp", `${formatNumber(latest.temp_c, 2)} C`); updateText("live-rh", `${formatNumber(latest.rh, 0)} %`); updateText("live-wind", `${formatNumber(latest.wind_m_s, 2)}`); updateText("live-gust", `${formatNumber(latest.wind_gust_m_s, 2)}`); updateText("live-wdir", `${formatNumber(latest.wind_dir_deg, 0)}`); updateText("live-uvi", `${formatNumber(latest.uvi, 2)}`); updateText("live-lux", `${formatNumber(latest.light_lux, 0)}`); updateText("live-battery", `${formatNumber(latest.battery_mv, 0)}`); updateText("live-supercap", `${formatNumber(latest.supercap_v, 2)}`); } else { updateText("live-temp", "--"); updateText("live-rh", "--"); updateText("live-wind", "--"); updateText("live-gust", "--"); updateText("live-wdir", "--"); updateText("live-uvi", "--"); updateText("live-lux", "--"); updateText("live-battery", "--"); updateText("live-supercap", "--"); } const forecastUrl = document.getElementById("forecast-url"); if (forecastUrl) { if (data.open_meteo_url) { forecastUrl.href = data.open_meteo_url; forecastUrl.textContent = data.open_meteo_url; } else { forecastUrl.textContent = "--"; } } const obs = data.observations || []; const forecastAll = (data.forecast && data.forecast.points) || []; const rangeStart = safeDate(data.range_start) || state.rangeStart; const rangeEnd = safeDate(data.range_end) || state.rangeEnd; const range = { axisStart: rangeStart, axisEnd: rangeEnd }; const obsFiltered = filterRange(obs, rangeStart, rangeEnd); const forecast = filterRange(forecastAll, rangeStart, rangeEnd); const obsTemps = obsFiltered.map((p) => p.temp_c); const obsWinds = obsFiltered.map((p) => p.wind_m_s); const obsGusts = obsFiltered.map((p) => p.wind_gust_m_s); const obsRH = obsFiltered.map((p) => p.rh); const obsUvi = obsFiltered.map((p) => p.uvi); const obsLux = obsFiltered.map((p) => p.light_lux); const fcTemps = forecast.map((p) => p.temp_c); const fcWinds = forecast.map((p) => p.wind_m_s); const fcGusts = forecast.map((p) => p.wind_gust_m_s); const fcRH = forecast.map((p) => p.rh); const fcPrecip = forecast.map((p) => p.precip_mm); const obsTempSummary = minMax(obsTemps); const obsWindSummary = minMax(obsWinds); const obsUviSummary = minMax(obsUvi); const obsLuxSummary = minMax(obsLux); const forecastTempSummary = minMax(fcTemps); const forecastPrecipTotal = sum(fcPrecip); const obsParts = []; if (obsTempSummary.min !== null) { obsParts.push(`temp_c ${obsTempSummary.min.toFixed(1)} to ${obsTempSummary.max.toFixed(1)}`); } if (obsWindSummary.max !== null) { obsParts.push(`wind_max ${obsWindSummary.max.toFixed(1)} m/s`); } if (obsUviSummary.max !== null) { obsParts.push(`uvi_max ${obsUviSummary.max.toFixed(1)}`); } if (obsLuxSummary.max !== null) { obsParts.push(`lux_max ${obsLuxSummary.max.toFixed(0)}`); } updateText("obs-summary", obsParts.length ? obsParts.join(" | ") : "--"); const forecastParts = []; if (forecastTempSummary.min !== null) { forecastParts.push(`temp_c ${forecastTempSummary.min.toFixed(1)} to ${forecastTempSummary.max.toFixed(1)}`); } if (forecastPrecipTotal !== null) { forecastParts.push(`precip_total ${forecastPrecipTotal.toFixed(1)} mm`); } updateText("forecast-summary", forecastParts.length ? forecastParts.join(" | ") : "--"); updateText("commentary", buildCommentary(latest, forecastAll)); const tempChart = { type: "line", data: { datasets: [ { label: "obs temp C", data: series(obsFiltered, "temp_c"), borderColor: colors.obs }, { label: "forecast temp C", data: series(forecast, "temp_c"), borderColor: colors.forecast }, ], }, options: baseOptions(range), }; upsertChart("chart-temp", tempChart); const windChart = { type: "line", data: { datasets: [ { label: "obs wind m/s", data: series(obsFiltered, "wind_m_s"), borderColor: colors.obs }, { label: "obs gust m/s", data: series(obsFiltered, "wind_gust_m_s"), borderColor: colors.gust }, { label: "forecast wind m/s", data: series(forecast, "wind_m_s"), borderColor: colors.forecast }, { label: "forecast gust m/s", data: series(forecast, "wind_gust_m_s"), borderColor: "#f7d79f" }, ], }, options: baseOptions(range), }; upsertChart("chart-wind", windChart); const rhChart = { type: "line", data: { datasets: [ { label: "obs humidity %", data: series(obsFiltered, "rh"), borderColor: colors.humidity }, { label: "forecast rh %", data: series(forecast, "rh"), borderColor: colors.forecast }, ], }, options: baseOptions(range), }; upsertChart("chart-rh", rhChart); const lightOptions = baseOptions(range); lightOptions.scales.y1 = { position: "right", ticks: { color: "#a4c4c4" }, grid: { drawOnChartArea: false }, }; const lightChart = { type: "line", data: { datasets: [ { label: "uvi", data: series(obsFiltered, "uvi"), borderColor: colors.uvi, yAxisID: "y" }, { label: "light lux", data: series(obsFiltered, "light_lux"), borderColor: colors.light, yAxisID: "y1" }, ], }, options: lightOptions, }; upsertChart("chart-light", lightChart); const powerOptions = baseOptions(range); powerOptions.scales.y1 = { position: "right", ticks: { color: "#a4c4c4" }, grid: { drawOnChartArea: false }, }; const powerChart = { type: "line", data: { datasets: [ { label: "battery mV", data: series(obsFiltered, "battery_mv"), borderColor: colors.obs, yAxisID: "y" }, { label: "supercap V", data: series(obsFiltered, "supercap_v"), borderColor: colors.forecast, yAxisID: "y1" }, ], }, options: powerOptions, }; upsertChart("chart-power", powerChart); const precipChart = { type: "bar", data: { datasets: [ { label: "forecast precip mm", data: series(forecast, "precip_mm"), backgroundColor: colors.precip }, ], }, options: baseOptions(range), }; upsertChart("chart-precip", precipChart); } function buildCommentary(latest, forecast) { if (!latest || !forecast || !forecast.length) { return "Waiting for forecast data..."; } const obsTs = new Date(latest.ts); if (Number.isNaN(obsTs.getTime())) { return "No valid observation timestamp yet."; } let nearest = null; let bestDiff = null; for (const p of forecast) { if (!p.ts) continue; const fcTs = new Date(p.ts); if (Number.isNaN(fcTs.getTime())) continue; const diff = Math.abs(obsTs - fcTs); if (bestDiff === null || diff < bestDiff) { bestDiff = diff; nearest = p; } } if (!nearest || bestDiff > 2 * 60 * 60 * 1000) { return "No nearby forecast point to compare yet."; } const parts = []; if (latest.temp_c !== null && nearest.temp_c !== null) { const delta = latest.temp_c - nearest.temp_c; parts.push(`temp ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} C`); } if (latest.wind_m_s !== null && nearest.wind_m_s !== null) { const delta = latest.wind_m_s - nearest.wind_m_s; parts.push(`wind ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} m/s`); } if (latest.wind_gust_m_s !== null && nearest.wind_gust_m_s !== null) { const delta = latest.wind_gust_m_s - nearest.wind_gust_m_s; parts.push(`gust ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} m/s`); } if (latest.rh !== null && nearest.rh !== null) { const delta = latest.rh - nearest.rh; parts.push(`rh ${delta >= 0 ? "+" : ""}${delta.toFixed(0)} %`); } if (!parts.length) { return "Not enough data to compute deviation yet."; } return `Now vs forecast: ${parts.join(", ")}`; } async function loadAndRender() { const range = computeRange(state.range, state.tz); state.rangeStart = range.start; state.rangeEnd = range.end; const params = new URLSearchParams({ range: state.range, bucket: state.bucket, start: range.start.toISOString(), end: range.end.toISOString(), }); try { const resp = await fetch(`/api/dashboard?${params.toString()}`, { cache: "no-store" }); if (!resp.ok) { updateText("commentary", `Dashboard error ${resp.status}`); return; } const data = await resp.json(); renderDashboard(data); } catch (err) { updateText("commentary", "Dashboard fetch failed."); } } function setupControls() { const buttons = document.querySelectorAll(".btn[data-range]"); buttons.forEach((btn) => { btn.addEventListener("click", () => { buttons.forEach((b) => b.classList.remove("active")); btn.classList.add("active"); state.range = btn.dataset.range; state.bucket = state.range === "6h" ? "1m" : "5m"; loadAndRender(); }); }); const tzButtons = document.querySelectorAll(".btn[data-tz]"); tzButtons.forEach((btn) => { btn.addEventListener("click", () => { tzButtons.forEach((b) => b.classList.remove("active")); btn.classList.add("active"); state.tz = btn.dataset.tz; loadAndRender(); }); }); } document.addEventListener("DOMContentLoaded", () => { setupControls(); loadAndRender(); if (state.timer) clearInterval(state.timer); state.timer = setInterval(loadAndRender, 60 * 1000); });