Spaces:
Sleeping
Sleeping
| /* βββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 & 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,"&").replace(/</g,"<").replace(/>/g,">"); | |
| } | |
| /* ββ Expose globals used by inline onclick handlers ββ */ | |
| const app = { goToView, closeModal }; | |