|
|
const $ = (s, c=document) => c.querySelector(s);
|
|
|
|
|
|
function fmtLocal(input){
|
|
|
try {
|
|
|
if (input == null) return "";
|
|
|
|
|
|
|
|
|
if (typeof input === "number") {
|
|
|
const ms = input < 1e12 ? input * 1000 : input;
|
|
|
return new Date(ms).toLocaleString(undefined, {
|
|
|
hour12: false,
|
|
|
timeZoneName: "short"
|
|
|
});
|
|
|
}
|
|
|
|
|
|
let s = String(input).trim();
|
|
|
|
|
|
|
|
|
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s)) {
|
|
|
s = s.replace(" ", "T");
|
|
|
}
|
|
|
|
|
|
|
|
|
const hasTZ = /([zZ]|[+\-]\d{2}:\d{2})$/.test(s);
|
|
|
const d = new Date(hasTZ ? s : s + "Z");
|
|
|
|
|
|
if (isNaN(d)) return s;
|
|
|
|
|
|
return d.toLocaleString(undefined, {
|
|
|
hour12: false,
|
|
|
timeZoneName: "short"
|
|
|
});
|
|
|
} catch {
|
|
|
return String(input);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function fetchJSON(url){
|
|
|
const r = await fetch(url, { credentials: "same-origin" });
|
|
|
if(!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
|
return r.json();
|
|
|
}
|
|
|
|
|
|
async function loadAll(){
|
|
|
const [summary, logs] = await Promise.all([
|
|
|
fetchJSON("/api/progress/summary"),
|
|
|
fetchJSON("/api/progress/logs?limit=200")
|
|
|
]);
|
|
|
|
|
|
|
|
|
$("#kpi-total").textContent = summary.total || 0;
|
|
|
$("#kpi-recycle").textContent = summary.totals?.Recyclable || 0;
|
|
|
$("#kpi-compost").textContent = summary.totals?.Compost || 0;
|
|
|
$("#kpi-landfill").textContent = summary.totals?.Landfill || 0;
|
|
|
|
|
|
|
|
|
if (window.Chart){
|
|
|
const donutCtx = document.getElementById("donutChart").getContext("2d");
|
|
|
if (window.__donut) window.__donut.destroy();
|
|
|
window.__donut = new Chart(donutCtx, {
|
|
|
type: "doughnut",
|
|
|
data: {
|
|
|
labels: ["Recyclable","Compost","Landfill","Other"],
|
|
|
datasets: [{
|
|
|
data: [
|
|
|
summary.totals?.Recyclable || 0,
|
|
|
summary.totals?.Compost || 0,
|
|
|
summary.totals?.Landfill || 0,
|
|
|
summary.totals?.Other || 0
|
|
|
],
|
|
|
backgroundColor: ["#34d399","#22c55e","#ef4444","#94a3b8"],
|
|
|
borderColor: "rgba(0,0,0,.15)", borderWidth: 1
|
|
|
}]
|
|
|
},
|
|
|
options: { plugins: { legend: { labels: { color: "#eafbf3" } } } }
|
|
|
});
|
|
|
|
|
|
const barCtx = document.getElementById("barChart").getContext("2d");
|
|
|
if (window.__bars) window.__bars.destroy();
|
|
|
const days = Object.keys(summary.per_day || {}).sort();
|
|
|
const mk = k => days.map(d => summary.per_day[d]?.[k] || 0);
|
|
|
window.__bars = new Chart(barCtx, {
|
|
|
type: "bar",
|
|
|
data: {
|
|
|
labels: days,
|
|
|
datasets: [
|
|
|
{ label:"Recyclable", data: mk("Recyclable"), backgroundColor:"#34d399" },
|
|
|
{ label:"Compost", data: mk("Compost"), backgroundColor:"#22c55e" },
|
|
|
{ label:"Landfill", data: mk("Landfill"), backgroundColor:"#ef4444" },
|
|
|
{ label:"Other", data: mk("Other"), backgroundColor:"#94a3b8" }
|
|
|
]
|
|
|
},
|
|
|
options: {
|
|
|
interaction: { mode:'index', intersect:false },
|
|
|
scales: {
|
|
|
x: { stacked:true, ticks:{ color:"#c2e5d9" }, grid:{ color:"rgba(255,255,255,.06)" } },
|
|
|
y: { stacked:true, ticks:{ color:"#c2e5d9", precision:0 }, grid:{ color:"rgba(255,255,255,.06)" } }
|
|
|
},
|
|
|
plugins: { legend: { labels: { color: "#eafbf3" } } }
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
|
|
|
const rows = logs.logs || [];
|
|
|
const tbody = $("#logRows");
|
|
|
tbody.innerHTML = "";
|
|
|
if (!rows.length) document.getElementById("emptyMsg").hidden = false;
|
|
|
rows.forEach(l => {
|
|
|
const tr = document.createElement("tr");
|
|
|
const label = normalizeLabel(l.label);
|
|
|
const badgeClass = label === "Recyclable" ? "badge--recycle" :
|
|
|
label === "Compost" ? "badge--compost" :
|
|
|
label === "Landfill" ? "badge--landfill" : "";
|
|
|
tr.innerHTML = `
|
|
|
<td>${fmtLocal(l.ts)}</td>
|
|
|
<td><span class="badge ${badgeClass}">${label}</span></td>
|
|
|
<td>${l.confidence ?? ""}</td>
|
|
|
<td>${l.city || ""}</td>
|
|
|
`;
|
|
|
tbody.appendChild(tr);
|
|
|
});
|
|
|
|
|
|
|
|
|
document.getElementById("exportCsv").onclick = () => {
|
|
|
const header = ["timestamp_iso","label","confidence","city"].join(",");
|
|
|
const lines = rows.map(r => [
|
|
|
r.ts,
|
|
|
`"${(r.label||"").replace(/"/g,'""')}"`,
|
|
|
r.confidence ?? "",
|
|
|
`"${(r.city||"").replace(/"/g,'""')}"`
|
|
|
].join(","));
|
|
|
const csv = [header, ...lines].join("\n");
|
|
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
|
|
const a = document.createElement("a");
|
|
|
a.href = URL.createObjectURL(blob);
|
|
|
a.download = `recycloai_logs_${new Date().toISOString().slice(0,10)}.csv`;
|
|
|
a.click();
|
|
|
URL.revokeObjectURL(a.href);
|
|
|
};
|
|
|
|
|
|
document.getElementById("clearLogs").onclick = async () => {
|
|
|
if (!confirm("Clear all your saved logs?")) return;
|
|
|
await fetch("/api/logs", { method:"DELETE", credentials:"same-origin" }).catch(()=>{});
|
|
|
location.reload();
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function normalizeLabel(s){
|
|
|
s = String(s || "").toLowerCase();
|
|
|
if (s.includes("recycl")) return "Recyclable";
|
|
|
if (s.includes("compost") || s.includes("organic")) return "Compost";
|
|
|
if (s.includes("landfill") || s.includes("trash") || s.includes("garbage")) return "Landfill";
|
|
|
return "Other";
|
|
|
}
|
|
|
|
|
|
document.addEventListener("DOMContentLoaded", loadAll);
|
|
|
|