Files
workout-suggester/internal/web/static/app.js
T
bdeb1337 d7342d6096 feat: workout-suggester, weather-aware workouts from a local Gemma
Go + Templ + HTMX app that checks the weather (Open-Meteo, MET Norway
fallback), scores run/ride/walk with plain rules, and has a local
open-weight model (Gemma 4 E2B via any OpenAI-compatible server) write
the plan. Routes are loops from your door (BRouter) plus signposted
OpenStreetMap routes (Overpass), with GPX export.
2026-10-11 19:29:58 +02:00

135 lines
5.2 KiB
JavaScript

// Small touches on top of HTMX: "📍 Here", remembering the form, the route
// map, quick follow-up chips and Enter-to-send.
(() => {
const ask = document.getElementById("ask");
const place = ask.elements.place;
const lat = ask.elements.lat;
const lon = ask.elements.lon;
const locate = document.getElementById("locate");
// Remember the last choices in this browser only.
const KEY = "workout-suggester:form";
const save = () => {
const d = Object.fromEntries(new FormData(ask));
try { localStorage.setItem(KEY, JSON.stringify(d)); } catch {}
};
try {
const d = JSON.parse(localStorage.getItem(KEY) || "{}");
for (const [k, v] of Object.entries(d)) {
const el = ask.elements[k];
if (!el) continue;
if (el instanceof RadioNodeList) {
for (const r of el) r.checked = r.value === v;
} else if (v) {
el.value = v;
}
}
if (lat.value) place.placeholder = "📍 your location";
} catch {}
ask.addEventListener("change", save);
ask.addEventListener("htmx:beforeRequest", save);
// Typing a place overrides the GPS fix.
place.addEventListener("input", () => {
lat.value = lon.value = "";
place.placeholder = "Town or city";
});
locate.addEventListener("click", () => {
if (!navigator.geolocation) return alert("This browser can't share its location. Type a town instead.");
locate.disabled = true;
locate.textContent = "📍 …";
navigator.geolocation.getCurrentPosition(
(pos) => {
lat.value = pos.coords.latitude.toFixed(5);
lon.value = pos.coords.longitude.toFixed(5);
place.value = "";
place.placeholder = "📍 your location";
locate.disabled = false;
locate.textContent = "📍 Here";
save();
ask.requestSubmit();
},
(err) => {
locate.disabled = false;
locate.textContent = "📍 Here";
alert("Couldn't get your location (" + err.message + "). Type a town instead.");
},
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }
);
});
// Draw route maps that arrive in HTMX swaps.
const COLORS = ["#2f8f4e", "#3b6cf6", "#c2410c"];
const drawMaps = (root) => {
if (!window.L) return;
for (const el of root.querySelectorAll(".map[data-map]:not(.leaflet-container)")) {
const d = JSON.parse(el.dataset.map);
// The view must exist before vector layers are added, or Leaflet throws.
const map = L.map(el, { scrollWheelZoom: false }).setView(d.you, 13);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 18,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
const layers = d.routes.map((r, i) =>
L.featureGroup([
L.polyline(r.ways, { color: COLORS[i % COLORS.length], weight: i === 0 ? 5 : 3, opacity: i === 0 ? 0.9 : 0.5 }),
L.circleMarker(r.join, { radius: 5, color: COLORS[i % COLORS.length], fillOpacity: 1 }).bindTooltip("Start here"),
]).bindTooltip(r.name, { sticky: true })
);
layers.forEach((l) => l.addTo(map));
L.circleMarker(d.you, { radius: 7, color: "#fff", weight: 2, fillColor: "#111", fillOpacity: 1 })
.bindTooltip("You").addTo(map);
const focus = (i) => {
layers.forEach((l, j) => l.eachLayer((x) => x.setStyle && x.setStyle({ weight: j === i ? 5 : 3, opacity: j === i ? 0.9 : 0.4 })));
layers[i].bringToFront();
map.fitBounds(layers[i].getBounds().extend(d.you), { padding: [20, 20] });
};
focus(0);
const list = el.parentElement.querySelectorAll("[data-route]");
list.forEach((li) =>
li.addEventListener("click", (e) => {
if (e.target.closest("a")) return;
list.forEach((x) => x.classList.toggle("active", x === li));
focus(+li.dataset.route);
})
);
}
};
// Follow-up chat: Enter to send, quick chips, auto-grow.
document.body.addEventListener("keydown", (e) => {
const t = e.target;
if (t.matches("#composer textarea") && e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
if (t.value.trim()) t.form.requestSubmit();
}
});
document.body.addEventListener("input", (e) => {
if (e.target.matches("#composer textarea")) {
e.target.style.height = "auto";
e.target.style.height = e.target.scrollHeight + "px";
}
});
document.body.addEventListener("click", (e) => {
const chip = e.target.closest("[data-ask]");
if (!chip) return;
const form = document.getElementById("composer");
form.elements.message.value = chip.dataset.ask;
form.requestSubmit();
});
document.body.addEventListener("htmx:afterRequest", (e) => {
if (e.target.id === "composer" && e.detail.successful) {
e.target.reset();
e.target.elements.message.style.height = "auto";
}
});
// outerHTML swaps replace the event target, so just look everywhere.
document.body.addEventListener("htmx:afterSettle", (e) => {
drawMaps(document);
if (e.target.id === "plan") e.target.scrollIntoView({ behavior: "smooth", block: "start" });
});
addEventListener("load", () => drawMaps(document));
})();