/* ============================================================ wird-tracker.js ============================================================ */ (function () { "use strict"; const API = "/api/wird"; // type: "count" | "juz" | "min" | "rating" const DAILY_WIRD = { durood: { label: "Durood", type: "count", unit: "count", target: 500 }, istighfar: { label: "Istighfar", type: "count", unit: "count", target: 200 }, quran: { label: "Quran", type: "juz", unit: "juz", target: 3 }, muraqabah: { label: "Muraqabah", type: "min", unit: "min", target: 10 }, wuqoof_qalbi: { label: "Wuquf Qalbi", type: "rating", unit: "", target: 3 }, }; const WIRD_META = { ...DAILY_WIRD, shaykh_meeting: { label: "Meeting w/ Shaykh", type: "meeting", unit: "meeting", target: 1 }, }; const RATING_LABELS = { 1: "distracted", 2: "scattered", 3: "present", 4: "attentive", 5: "absorbed", }; const MEETING_CYCLE_DAYS = 21; const $ = (sel, ctx = document) => ctx.querySelector(sel); const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)]; const isoDate = (d = new Date()) => d.toISOString().slice(0, 10); function daysBetween(a, b) { return Math.round((new Date(b) - new Date(a)) / 86400000); } function addDays(iso, n) { const d = new Date(iso); d.setDate(d.getDate() + n); return isoDate(d); } function fmtDisplay(iso) { return new Date(iso + "T00:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", }); } function fmtValue(type, val) { const meta = WIRD_META[type]; if (!meta) return val; if (meta.type === "rating") return val ? `${RATING_LABELS[val] || val} (${val}/5)` : "—"; if (meta.type === "meeting") return val >= 1 ? "✓ attended" : "✗ missed"; return `${Number(val).toLocaleString()} ${meta.unit}`; } function statusClass(type, val) { const meta = WIRD_META[type]; if (!meta || !val || val <= 0) return ""; if (meta.type === "rating") { if (val >= 4) return "over"; if (val >= 3) return "done"; return "miss"; } if (val >= meta.target * 1.1) return "over"; if (val >= meta.target) return "done"; return "miss"; } let allEntries = []; let todayMap = {}; let meetingLog = []; let chart = null; document.addEventListener("DOMContentLoaded", async () => { setTodayLabel(); await loadAll(); renderToday(); renderMeetingPanel(); renderHistory(); renderChart(); bindControls(); }); function setTodayLabel() { $("#today-date").textContent = new Date().toLocaleDateString("en-GB", { weekday: "long", day: "numeric", month: "long", year: "numeric", }); } async function loadAll() { try { const r = await fetch(`${API}/entries`); if (!r.ok) throw new Error(r.status); allEntries = await r.json(); buildTodayMap(); buildMeetingLog(); } catch (e) { console.error("Wird API error:", e); } } function buildTodayMap() { const today = isoDate(); todayMap = {}; for (const e of allEntries) { if (e.date !== today) continue; // For ratings, take the latest entry (last log wins), not a sum if (WIRD_META[e.wirdType]?.type === "rating") { const existing = todayMap[e.wirdType]; if (!existing || e.createdAt > existing.createdAt) { todayMap[e.wirdType] = Number(e.value); } } else { todayMap[e.wirdType] = (todayMap[e.wirdType] || 0) + Number(e.value); } } } function buildMeetingLog() { meetingLog = allEntries .filter(e => e.wirdType === "shaykh_meeting" && Number(e.value) >= 1) .sort((a, b) => b.date.localeCompare(a.date)); } async function postEntry(payload) { const r = await fetch(`${API}/entries`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!r.ok) throw new Error(await r.text()); return r.json(); } function renderToday() { const wrap = $("#wird-cards"); wrap.innerHTML = ""; let done = 0; const types = Object.keys(DAILY_WIRD); for (const type of types) { const meta = DAILY_WIRD[type]; const val = todayMap[type] || 0; const cls = statusClass(type, val); if (cls === "done" || cls === "over") done++; const card = document.createElement("div"); card.className = `wird-card ${cls}`; card.dataset.type = type; if (meta.type === "rating") { card.innerHTML = buildRatingCard(meta, val); } else { const pct = Math.min((val / meta.target) * 100, 100); card.innerHTML = `
${meta.label}
${fmtValue(type, val)} / ${meta.target.toLocaleString()} ${meta.unit}
`; } card.querySelector(".card-log-btn").addEventListener("click", () => openModal(type)); wrap.appendChild(card); } const pct = Math.round((done / types.length) * 100); $("#today-progress-fill").style.width = pct + "%"; $("#today-progress-label").textContent = `${done} / ${types.length} complete`; } function buildRatingCard(meta, val) { // 5 pips, filled up to current rating const pips = [1,2,3,4,5].map(n => { const filled = val >= n; const isMin = n === meta.target; return ``; }).join(""); const lbl = val ? `${RATING_LABELS[val]} (${val}/5)` : "not logged"; return `
${meta.label}
${pips}
${lbl}
`; } const modal = $("#log-modal"); const form = $("#log-form"); const typeEl = $("#log-type"); const valEl = $("#log-value"); const dateEl = $("#log-date"); const notesEl = $("#log-notes"); const ratingEl = $("#log-rating-value"); // Pip click handlers let selectedRating = 0; $$("#modal-pips .modal-pip").forEach(btn => { btn.addEventListener("click", () => { selectedRating = parseInt(btn.dataset.val); ratingEl.value = selectedRating; $$("#modal-pips .modal-pip").forEach(b => { const v = parseInt(b.dataset.val); b.classList.toggle("selected", v <= selectedRating); }); }); }); function openModal(preType = null) { form.reset(); selectedRating = 0; ratingEl.value = ""; $$("#modal-pips .modal-pip").forEach(b => b.classList.remove("selected")); dateEl.value = isoDate(); if (preType) typeEl.value = preType; updateModalFields(); modal.showModal(); } function openMeetingModal() { form.reset(); dateEl.value = isoDate(); typeEl.value = "shaykh_meeting"; updateModalFields(); modal.showModal(); } function updateModalFields() { const type = typeEl.value; const isRating = type === "wuqoof_qalbi"; const isMeeting = type === "shaykh_meeting"; $("#value-group").style.display = (!isRating && !isMeeting) ? "" : "none"; $("#rating-group").style.display = isRating ? "" : "none"; $("#shaykh-group").style.display = isMeeting ? "" : "none"; if (!isRating && !isMeeting) { const meta = DAILY_WIRD[type]; if (meta) valEl.placeholder = `Target: ${meta.target} ${meta.unit}`; } } typeEl.addEventListener("change", updateModalFields); $("#modal-cancel").addEventListener("click", () => modal.close()); form.addEventListener("submit", async (e) => { e.preventDefault(); const type = typeEl.value; const isRating = type === "wuqoof_qalbi"; const isMtg = type === "shaykh_meeting"; if (isRating && !ratingEl.value) { alert("Please select a rating."); return; } const payload = { wirdType: type, date: dateEl.value, value: isRating ? Number(ratingEl.value) : isMtg ? 1 : Number(valEl.value), notes: notesEl.value.trim() || null, }; try { const saved = await postEntry(payload); allEntries.push(saved); buildTodayMap(); buildMeetingLog(); renderToday(); renderMeetingPanel(); renderHistory(); renderChart(); modal.close(); } catch (err) { alert("Could not save entry: " + err.message); } }); function renderMeetingPanel() { const today = isoDate(); const last = meetingLog[0] || null; const lastDate = last ? last.date : null; const nextDate = lastDate ? addDays(lastDate, MEETING_CYCLE_DAYS) : null; const daysToNext = nextDate ? daysBetween(today, nextDate) : null; let statusHtml = ""; if (!lastDate) { statusHtml = `No meetings logged yet`; } else if (daysToNext > 0) { statusHtml = `Next due in ${daysToNext} day${daysToNext !== 1 ? "s" : ""} — ${fmtDisplay(nextDate)}`; } else if (daysToNext === 0) { statusHtml = `Due today`; } else { const ov = Math.abs(daysToNext); statusHtml = `${ov} day${ov !== 1 ? "s" : ""} overdue — expected ${fmtDisplay(nextDate)}`; } const cycles = buildCycleInsights(); const cycleRows = cycles.map(c => ` ${fmtDisplay(c.start)} → ${fmtDisplay(c.end)} ${c.met ? `✓ met — ${fmtDisplay(c.metOn)}` : c.ongoing ? `ongoing` : `✗ missed`} ${c.met ? `day ${c.dayOfCycle}` : "—"} `).join(""); $("#meeting-panel-body").innerHTML = `
Last meeting ${lastDate ? fmtDisplay(lastDate) : "—"}
Next expected ${nextDate ? fmtDisplay(nextDate) : "—"}
${statusHtml}

3-week cycles

${cycles.length === 0 ? `

Log at least one meeting to see cycle insights.

` : `${cycleRows}
Cycle windowStatusWhen
`}
`; } function buildCycleInsights() { if (meetingLog.length === 0) return []; const dates = [...meetingLog].reverse().map(e => e.date); const today = isoDate(); const cycles = []; let start = dates[0]; while (start <= today) { const end = addDays(start, MEETING_CYCLE_DAYS - 1); const hit = dates.find(d => d >= start && d <= end); cycles.push({ start, end, met: !!hit, metOn: hit || null, dayOfCycle: hit ? daysBetween(start, hit) + 1 : null, ongoing: end > today, }); start = addDays(end, 1); } return cycles.reverse(); } function renderHistory() { const tbody = $("#history-body"); tbody.innerHTML = ""; const sorted = [...allEntries] .filter(e => e.wirdType !== "shaykh_meeting") .sort((a, b) => b.date.localeCompare(a.date)); if (sorted.length === 0) { tbody.innerHTML = `No entries yet.`; return; } for (const e of sorted) { const meta = WIRD_META[e.wirdType] || {}; const cls = statusClass(e.wirdType, e.value); const badgeTxt = cls === "over" ? (meta.type === "rating" ? "flourishing" : "above") : cls === "done" ? (meta.type === "rating" ? "present" : "met") : cls === "miss" ? (meta.type === "rating" ? "below" : "below") : "—"; const tr = document.createElement("tr"); tr.innerHTML = ` ${e.date} ${meta.label || e.wirdType} ${fmtValue(e.wirdType, e.value)} ${badgeTxt} ${e.notes || ""} `; tbody.appendChild(tr); } } function renderChart() { const type = $("#trend-type").value; const days = parseInt($("#trend-range").value); const meta = WIRD_META[type]; const isRating = meta.type === "rating"; const now = new Date(); const labels = []; const vals = []; for (let i = days - 1; i >= 0; i--) { const d = new Date(now); d.setDate(d.getDate() - i); const iso = isoDate(d); labels.push(iso.slice(5)); const dayEntries = allEntries.filter(e => e.date === iso && e.wirdType === type); if (isRating) { // Use the last logged rating for the day const last = dayEntries.sort((a,b) => a.createdAt > b.createdAt ? 1 : -1).pop(); vals.push(last ? Number(last.value) : 0); } else { vals.push(dayEntries.reduce((s, e) => s + Number(e.value), 0)); } } const nonZero = vals.filter(v => v > 0); const avg = nonZero.length ? (isRating ? (nonZero.reduce((a,b)=>a+b,0)/nonZero.length).toFixed(1) : Math.round(nonZero.reduce((a,b)=>a+b,0)/nonZero.length)) : 0; const max = nonZero.length ? Math.max(...nonZero) : 0; const streak = calcStreak(type); if (isRating) { $("#trend-stats").innerHTML = `
${avg}avg rating
${RATING_LABELS[max] || "—"}best day
${streak}dstreak e ${meta.target}
${nonZero.length}days logged
`; } else { $("#trend-stats").innerHTML = `
${Number(avg).toLocaleString()}avg / day
${Number(max).toLocaleString()}best day
${streak}dcurrent streak
${meta.target.toLocaleString()}daily target
`; } const cs = getComputedStyle(document.documentElement); const accent = cs.getPropertyValue("--wird-accent").trim() || "#7c5c3a"; const gridColor = cs.getPropertyValue("--border").trim() || "#d7d7d7"; const tickColor = cs.getPropertyValue("--muted").trim() || "#666"; const ctx = $("#trend-chart").getContext("2d"); if (chart) chart.destroy(); chart = new Chart(ctx, { type: "line", data: { labels, datasets: [ { label: meta.label, data: vals, borderColor: accent, backgroundColor: accent + "22", fill: true, tension: .35, pointRadius: 3, pointHoverRadius: 5, }, { label: "Target", data: vals.map(() => meta.target), borderColor: "rgba(74,124,89,.5)", borderDash: [5,4], pointRadius: 0, fill: false, tension: 0, }, ], }, options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => { const v = ctx.parsed.y; if (isRating && ctx.datasetIndex === 0) return `${meta.label}: ${RATING_LABELS[v] || v} (${v}/5)`; return `${ctx.dataset.label}: ${v}`; }, }, }, }, scales: { x: { grid: { color: gridColor + "22" }, ticks: { color: tickColor, font: { family: "monospace", size: 10 }, maxTicksLimit: 10 }, }, y: { grid: { color: gridColor + "22" }, ticks: { color: tickColor, font: { family: "monospace", size: 10 }, // For rating chart: show labels instead of numbers ...(isRating ? { min: 0, max: 5, stepSize: 1, callback: v => RATING_LABELS[v] || (v === 0 ? "" : v), } : {}), }, beginAtZero: true, ...(isRating ? { min: 0, max: 5 } : {}), }, }, }, }); } function calcStreak(type) { const meta = WIRD_META[type]; let streak = 0; const now = new Date(); for (let i = 0; i < 365; i++) { const d = new Date(now); d.setDate(d.getDate() - i); const iso = isoDate(d); const entries = allEntries.filter(e => e.date === iso && e.wirdType === type); let total; if (meta.type === "rating") { const last = entries.sort((a,b) => a.createdAt > b.createdAt ? 1 : -1).pop(); total = last ? Number(last.value) : 0; } else { total = entries.reduce((s, e) => s + Number(e.value), 0); } if (total >= meta.target) streak++; else if (i > 0) break; } return streak; } function bindControls() { $("#open-log-btn").addEventListener("click", () => openModal()); $("#log-meeting-btn").addEventListener("click", openMeetingModal); $("#trend-type").addEventListener("change", renderChart); $("#trend-range").addEventListener("change", renderChart); } })();