BREATHE / frontend /static /js /app.js
tannuiscoding's picture
added app.py
5a264f5
/* ═══════════════════════════════════════════════
BREATHE β€” Front-end Application
═══════════════════════════════════════════════ */
const API = ""; // same-origin; change to http://localhost:5000 if running separately
const ADVICE = {
Minimal: "Great job! Your stress levels are minimal. Keep up your healthy routines and sleep schedule.",
Mild: "Your stress is mild. Consider a short walk or 5-minute breathing exercise today.",
Moderate: "Moderate stress detected. Try to schedule a proper break, reduce screen time and stay hydrated.",
Severe: "High stress detected. Please prioritise rest, reach out to someone you trust, and consider reducing workload.",
Critical: "Critical stress indicators found. We strongly recommend speaking with a mental health professional. You are not alone.",
};
const LEVEL_COLOURS = {
Minimal: "#4ade80",
Mild: "#86efac",
Moderate: "#fbbf24",
Severe: "#fb923c",
Critical: "#f87171",
};
/* ─── Helpers ─────────────────────────────────────────────── */
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => el.querySelectorAll(s);
function setClass(el, cls, on) {
el && (on ? el.classList.add(cls) : el.classList.remove(cls));
}
function formatDate(iso) {
const d = new Date(iso);
return d.toLocaleString(undefined, {
year: "numeric", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
async function apiFetch(path, options = {}) {
const res = await fetch(API + path, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
return json;
}
/* ─── App State ───────────────────────────────────────────── */
const state = {
user: null,
timelineChart: null,
distChart: null,
historyPage: 1,
};
/* ═══════════════════════════════════════════════
AUTH
═══════════════════════════════════════════════ */
function showAuth() { setClass($("#auth-screen"), "active", true); setClass($("#app-screen"), "active", false); }
function showApp() { setClass($("#auth-screen"), "active", false); setClass($("#app-screen"), "active", true); }
// Tab switching
$$(".tab").forEach(btn => {
btn.addEventListener("click", () => {
$$(".tab").forEach(t => t.classList.remove("active"));
btn.classList.add("active");
$$(".auth-form").forEach(f => f.classList.remove("active"));
$(`#${btn.dataset.tab}-form`).classList.add("active");
});
});
// Login
$("#login-form").addEventListener("submit", async e => {
e.preventDefault();
const errEl = $("#login-error");
errEl.classList.add("hidden");
try {
const data = await apiFetch("/api/auth/login", {
method: "POST",
body: JSON.stringify({
email: $("#login-identity").value,
username: $("#login-identity").value,
password: $("#login-password").value,
}),
});
state.user = data.user;
onLogin();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove("hidden");
}
});
// Signup
$("#signup-form").addEventListener("submit", async e => {
e.preventDefault();
const errEl = $("#signup-error");
errEl.classList.add("hidden");
try {
const data = await apiFetch("/api/auth/signup", {
method: "POST",
body: JSON.stringify({
username: $("#signup-username").value,
email: $("#signup-email").value,
password: $("#signup-password").value,
}),
});
state.user = data.user;
onLogin();
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove("hidden");
}
});
// Logout
$("#logout-btn").addEventListener("click", async () => {
await apiFetch("/api/auth/logout", { method: "POST" }).catch(() => {});
state.user = null;
showAuth();
});
async function onLogin() {
showApp();
$("#sidebar-username").textContent = state.user.username;
$("#dash-greeting").textContent = `Welcome back, ${state.user.username}!`;
await loadDashboard();
goToView("dashboard");
}
/* ─── Check existing session on load ─────────────── */
(async () => {
try {
const data = await apiFetch("/api/auth/me");
state.user = data.user;
onLogin();
} catch {
showAuth();
}
})();
/* ═══════════════════════════════════════════════
NAVIGATION
═══════════════════════════════════════════════ */
function goToView(name) {
$$(".view").forEach(v => v.classList.remove("active"));
$(`#view-${name}`).classList.add("active");
$$(".nav-item").forEach(b => {
b.classList.toggle("active", b.dataset.view === name);
});
if (name === "history") loadHistory(1);
if (name === "dashboard") loadDashboard();
}
$$(".nav-item").forEach(btn => {
btn.addEventListener("click", () => goToView(btn.dataset.view));
});
/* ═══════════════════════════════════════════════
DASHBOARD
═══════════════════════════════════════════════ */
async function loadDashboard() {
try {
const data = await apiFetch("/api/assessments/summary");
const s = data.summary;
const tl = data.timeline;
if (!s || !Object.keys(s).length) {
// No data yet
return;
}
// Stats
$("#stat-total .stat-value").textContent = s.total_assessments;
$("#stat-latest .stat-value").textContent = stressIcon(s.latest_label) + " " + (s.latest_label || "β€”");
$("#stat-latest .stat-value").style.fontSize = "1.2rem";
$("#stat-avg .stat-value").textContent = s.avg_score ? (s.avg_score * 100).toFixed(1) + "%" : "β€”";
// Trend
if (tl.length >= 2) {
const diff = tl[tl.length-1].fused_score - tl[tl.length-2].fused_score;
const el = $("#stat-trend .stat-value");
el.textContent = diff > 0.01 ? "β–² Up" : diff < -0.01 ? "β–Ό Down" : "β†’ Stable";
el.style.color = diff > 0.01 ? "var(--danger)" : diff < -0.01 ? "var(--success)" : "var(--text-muted)";
el.style.fontSize = "1rem";
}
// Timeline chart
buildTimelineChart(tl);
// Distribution chart
buildDistChart(s.label_distribution);
// Recent list (last 5)
const recent = [...tl].reverse().slice(0, 5);
const listEl = $("#recent-list");
listEl.innerHTML = recent.length
? recent.map(r => assessmentItemHTML(r)).join("")
: `<p class="empty-state">No assessments yet.</p>`;
listEl.querySelectorAll(".assessment-item").forEach(el => {
el.addEventListener("click", () => openDetailModal(el.dataset.id));
});
} catch (err) {
console.error("Dashboard load error:", err);
}
}
function buildTimelineChart(tl) {
const ctx = $("#timeline-chart").getContext("2d");
if (state.timelineChart) state.timelineChart.destroy();
state.timelineChart = new Chart(ctx, {
type: "line",
data: {
labels: tl.map(r => r.date),
datasets: [{
label: "Stress Score",
data: tl.map(r => +(r.fused_score * 100).toFixed(1)),
borderColor: "#4f8ef7",
backgroundColor: "rgba(79,142,247,.08)",
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: tl.map(r => LEVEL_COLOURS[r.fused_label] || "#4f8ef7"),
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: {
ticks: { color: "#7a8aa0", maxTicksLimit: 8 },
grid: { color: "#2a3348" },
},
y: {
min: 0,
max: 100,
ticks: { color: "#7a8aa0", callback: v => v + "%" },
grid: { color: "#2a3348" },
},
},
},
});
}
function buildDistChart(dist) {
const ctx = $("#distribution-chart").getContext("2d");
if (state.distChart) state.distChart.destroy();
const labels = Object.keys(dist);
const values = Object.values(dist);
const colours = labels.map(l => LEVEL_COLOURS[l] || "#4f8ef7");
state.distChart = new Chart(ctx, {
type: "doughnut",
data: {
labels,
datasets: [{ data: values, backgroundColor: colours, borderWidth: 2, borderColor: "#161b27" }],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: "#e2e8f0", font: { size: 12 } } },
},
cutout: "60%",
},
});
}
/* ═══════════════════════════════════════════════
NEW ASSESSMENT
═══════════════════════════════════════════════ */
$("#assess-form").addEventListener("submit", async e => {
e.preventDefault();
const btn = $("#assess-submit");
const errEl = $("#assess-error");
errEl.classList.add("hidden");
btn.disabled = true;
btn.textContent = "Analysing…";
try {
// Build psychometric payload
const psycho = {};
const numFields = [
"Sleep_Duration","Sleep_Quality","Work_Hours","Physical_Activity",
"Screen_Time","Travel_Time","Social_Interactions","Caffeine_Intake",
"Alcohol_Intake","Blood_Pressure","Cholesterol_Level","Blood_Sugar_Level",
];
const catFields = ["Gender","Occupation","Smoking_Status","Diet_Quality"];
let hasPsycho = false;
numFields.forEach(f => {
const el = $(`[name="${f}"]`, $("#assess-form"));
if (el && el.value !== "") { psycho[f] = parseFloat(el.value); hasPsycho = true; }
});
catFields.forEach(f => {
const el = $(`[name="${f}"]`, $("#assess-form"));
if (el && el.value !== "") { psycho[f] = el.value; hasPsycho = true; }
});
const textNote = $("#text-note").value.trim();
if (!hasPsycho && !textNote) {
throw new Error("Please fill in at least one section.");
}
const body = {
psychometric: hasPsycho ? psycho : null,
text_note: textNote || null,
};
const data = await apiFetch("/api/assessments", {
method: "POST",
body: JSON.stringify(body),
});
showResult(data.prediction, data.assessment);
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove("hidden");
} finally {
btn.disabled = false;
btn.textContent = "Analyse My Stress β†’";
}
});
function showResult(pred, assessment) {
const panel = $("#result-panel");
panel.classList.remove("hidden");
panel.scrollIntoView({ behavior: "smooth", block: "start" });
const score = pred.fused_score;
const label = pred.fused_label;
const colour = LEVEL_COLOURS[label] || "#4f8ef7";
const perc = (score * 100).toFixed(1);
// Gauge
const circumference = 2 * Math.PI * 50; // r=50
const filled = (score * circumference).toFixed(1);
const arc = $("#gauge-arc");
arc.style.strokeDasharray = `${filled} ${circumference}`;
arc.style.stroke = colour;
$("#gauge-score").textContent = perc + "%";
$("#gauge-label").textContent = label;
$("#gauge-label").style.color = colour;
// Details
$("#res-psycho-val").textContent = pred.psycho_label
? `${pred.psycho_label} (${(pred.psycho_score * 100).toFixed(1)}%)`
: "β€”";
$("#res-text-val").textContent = pred.text_label
? `${pred.text_label} (${(pred.text_score * 100).toFixed(1)}%)`
: "β€”";
$("#res-modality").textContent = pred.modality_used || "β€”";
$("#res-time").textContent = assessment ? formatDate(assessment.created_at) : new Date().toLocaleString();
// Advice
$("#result-advice").textContent = ADVICE[label] || "";
}
/* ═══════════════════════════════════════════════
HISTORY
═══════════════════════════════════════════════ */
async function loadHistory(page = 1) {
state.historyPage = page;
const listEl = $("#history-list");
const pagEl = $("#history-pagination");
listEl.innerHTML = `<p class="empty-state">Loading…</p>`;
pagEl.innerHTML = "";
try {
const data = await apiFetch(`/api/assessments?page=${page}&per_page=15`);
if (!data.assessments.length) {
listEl.innerHTML = `<p class="empty-state">No assessments yet. Take your first one!</p>`;
return;
}
listEl.innerHTML = data.assessments.map(a => assessmentItemHTML({
id: a.id,
date: formatDate(a.created_at),
fused_score: a.fused_score,
fused_label: a.fused_label,
modality: a.modality_used,
})).join("");
listEl.querySelectorAll(".assessment-item").forEach(el => {
el.addEventListener("click", () => openDetailModal(el.dataset.id));
});
// Pagination
if (data.pages > 1) {
for (let p = 1; p <= data.pages; p++) {
const btn = document.createElement("button");
btn.className = `page-btn${p === page ? " active" : ""}`;
btn.textContent = p;
btn.addEventListener("click", () => loadHistory(p));
pagEl.appendChild(btn);
}
}
} catch (err) {
listEl.innerHTML = `<p class="empty-state" style="color:var(--danger)">${err.message}</p>`;
}
}
function assessmentItemHTML({ id, date, fused_score, fused_label, modality }) {
const levelKey = (fused_label || "").toLowerCase();
const scoreStr = fused_score != null ? (fused_score * 100).toFixed(1) + "%" : "β€”";
return `
<div class="assessment-item" data-id="${id}">
<span class="assess-badge level-bg-${levelKey}">${stressIcon(fused_label)} ${fused_label || "β€”"}</span>
<span class="assess-date">${date || ""}</span>
<span class="assess-score">${scoreStr}</span>
${modality ? `<span class="assess-modality">${modality}</span>` : ""}
</div>`;
}
function stressIcon(label) {
const icons = { Minimal: "🟒", Mild: "🟑", Moderate: "🟠", Severe: "πŸ”΄", Critical: "🚨" };
return icons[label] || "βšͺ";
}
/* ═══════════════════════════════════════════════
DETAIL MODAL
═══════════════════════════════════════════════ */
async function openDetailModal(id) {
const modal = $("#detail-modal");
const bodyEl = $("#modal-body");
bodyEl.innerHTML = "<p style='color:var(--text-muted)'>Loading…</p>";
modal.classList.remove("hidden");
try {
const data = await apiFetch(`/api/assessments/${id}`);
const a = data.assessment;
const psycho = a.psychometric_data
? Object.entries(a.psychometric_data)
.map(([k,v]) => `<div class="modal-kv-item"><div class="modal-kv-key">${k.replace(/_/g," ")}</div><div class="modal-kv-val">${v}</div></div>`)
.join("")
: "<p style='color:var(--text-muted);font-size:.85rem'>Not provided</p>";
bodyEl.innerHTML = `
<div class="result-item" style="margin-bottom:8px">
<span class="result-key">Fused Stress Level</span>
<span class="result-val level-${(a.fused_label||"").toLowerCase()}">${stressIcon(a.fused_label)} ${a.fused_label} (${a.fused_score != null ? (a.fused_score*100).toFixed(1)+"%" : "β€”"})</span>
</div>
<div class="result-item" style="margin-bottom:8px">
<span class="result-key">Psychometric</span>
<span class="result-val">${a.psycho_label || "β€”"}${a.psycho_score != null ? " (" + (a.psycho_score*100).toFixed(1)+"%)" : ""}</span>
</div>
<div class="result-item" style="margin-bottom:8px">
<span class="result-key">Text Sentiment</span>
<span class="result-val">${a.text_label || "β€”"}${a.text_score != null ? " (" + (a.text_score*100).toFixed(1)+"%)" : ""}</span>
</div>
<div class="result-item" style="margin-bottom:16px">
<span class="result-key">Date &amp; Time</span>
<span class="result-val">${formatDate(a.created_at)}</span>
</div>
${a.text_note ? `
<h4 style="color:var(--text-muted);font-size:.78rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">Text Note</h4>
<div style="background:var(--surface2);padding:12px 14px;border-radius:8px;font-size:.9rem;margin-bottom:16px;line-height:1.6">${escapeHtml(a.text_note)}</div>
` : ""}
<h4 style="color:var(--text-muted);font-size:.78rem;text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">Psychometric Data</h4>
<div class="modal-kv">${psycho}</div>
<div style="margin-top:14px;padding:12px 14px;border-radius:8px;border-left:4px solid var(--accent);background:rgba(79,142,247,.07);font-size:.88rem;line-height:1.6">
${ADVICE[a.fused_label] || ""}
</div>
`;
} catch (err) {
bodyEl.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`;
}
}
function closeModal() { $("#detail-modal").classList.add("hidden"); }
function escapeHtml(str) {
return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
/* ── Expose globals used by inline onclick handlers ── */
const app = { goToView, closeModal };