/* ============================================================ wird-tracker.js (full replacement) ============================================================ */ (function () { "use strict"; const API = "/api/wird"; // type: "count" | "juz" | "min" | "rating" | "nafl" | "khatm" const DAILY_WIRD = { durood: { label: "Durood", type: "count", unit: "count", target: 300 }, istighfar: { label: "Istighfar", type: "count", unit: "count", target: 200 }, quran: { label: "Qurʾān", type: "juz", unit: "juz", target: 2 }, muraqabah: { label: "Murāqabah", type: "min", unit: "min", target: 10 }, wuqoof_qalbi: { label: "Wuqūf Qalbī", type: "rating", unit: "", target: 3 }, Allah: { label: "Allah's Name", type: "count", unit: "count", target: 500 }, }; // Nafl prayers — stored in wird_entries, value=1 when done const NAFL_WIRD = { salatul_tawbah: { label: "Ṣalāt al-Tawbah", type: "nafl", unit: "", target: 1, rakats: 2, desc: "2 rakʿāt · repentance", icon: "🤲" }, salatul_hajaat: { label: "Ṣalāt al-Ḥājah", type: "nafl", unit: "", target: 1, rakats: 2, desc: "2 rakʿāt · need & supplication", icon: "🌙" }, tahajjud: { label: "Tahajjud", type: "nafl", unit: "", target: 1, rakats: 0, desc: "night vigil", icon: "⭐" }, }; const WIRD_META = { ...DAILY_WIRD, ...NAFL_WIRD, shaykh_meeting: { label: "Meeting w/ Shaykh", type: "meeting", unit: "meeting", target: 1 }, khatm: { label: "Khatm", type: "khatm", unit: "", target: 1 }, motalah: { label: "Mutālaʿah", type: "motalah", unit: "min", target: 30 }, }; const RATING_LABELS = { 1: "distracted", 2: "scattered", 3: "present", 4: "attentive", 5: "absorbed", }; const KHATM_TYPES = { tilawah: "Tilāwah", hifz: "Ḥifẓ", tadabbur: "Tadabbur", }; const MEETING_CYCLE_DAYS = 21; const HEATMAP_WEEKS = 52; 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"; if (meta.type === "nafl") return val >= 1 ? "✓ prayed" : "—"; if (meta.type === "khatm") return val >= 1 ? "✓ completed" : "—"; 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"; } // ── Mutālaʿah (Study) ───────────────────────────────────── const MOTALAH_API = "/api/wird/motalah"; const CALIBRE_API = "/api/calibre/books"; // see controller below const MOTALAH_TARGET = 30; // daily target in minutes let motalahEntries = []; let calibreBooks = []; async function loadMotalah() { try { const [mRes, bRes] = await Promise.all([ fetch(MOTALAH_API), fetch(CALIBRE_API), ]); if (mRes.ok) motalahEntries = await mRes.json(); if (bRes.ok) calibreBooks = await bRes.json(); } catch (e) { console.warn("Mutalaah load error:", e); } } function renderMotalah() { const today = isoDate(); const todayM = motalahEntries.filter(e => e.date === today); const todayMins = todayM.reduce((s, e) => s + Number(e.durationMinutes), 0); // streak 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 mins = motalahEntries.filter(e => e.date === iso) .reduce((s, e) => s + Number(e.durationMinutes), 0); if (mins >= MOTALAH_TARGET) streak++; else if (i > 0) break; } // month total const monthStart = today.slice(0, 7); const monthMins = motalahEntries .filter(e => e.date.startsWith(monthStart)) .reduce((s, e) => s + Number(e.durationMinutes), 0); const monthHrs = (monthMins / 60).toFixed(1); // unique books touched (all time) const allBooks = new Set( motalahEntries.flatMap(e => e.bookIds || []) ); $("#motalah-today-mins").textContent = todayMins || "0"; $("#motalah-streak").textContent = streak; $("#motalah-total-hrs").textContent = monthHrs; $("#motalah-books-count").textContent = allBooks.size; // sessions list (20 most recent) const wrap = $("#motalah-sessions-wrap"); const sorted = [...motalahEntries].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 20); if (sorted.length === 0) { wrap.innerHTML = `

No study sessions logged yet.

`; return; } wrap.innerHTML = sorted.map(e => { const bookNames = (e.bookIds || []) .map(id => { const b = calibreBooks.find(b => String(b.id) === String(id)); return b ? `${b.title}` : ""; }).join(""); const note = e.notes ? ` — ${e.notes}` : ""; return `
${e.date} ${e.durationMinutes} min ${bookNames || ""}${note}
`; }).join(""); } // Modal const motalahModal = $("#motalah-modal"); const motalahForm = $("#motalah-form"); const motalahDateEl = $("#motalah-date"); const motalahDurEl = $("#motalah-duration"); const motalahBooksEl = $("#motalah-books"); const motalahNotesEl = $("#motalah-notes"); // ── Book search UI ──────────────────────────────────────── // selectedBookIds: Set of ids currently picked in the modal let selectedBookIds = new Set(); const bookSearchInput = document.getElementById("motalah-book-search"); const bookSearchResults = document.getElementById("book-search-results"); const selectedChipsWrap = document.getElementById("selected-books-chips"); function bookById(id) { return calibreBooks.find(b => String(b.id) === String(id)); } function renderSelectedChips() { selectedChipsWrap.innerHTML = [...selectedBookIds].map(id => { const b = bookById(id); const label = b ? b.title : id; return ` ${label} `; }).join(""); // Sync hidden select so the existing submit handler still works motalahBooksEl.innerHTML = [...selectedBookIds] .map(id => ``).join(""); } function renderBookResults(query) { const q = query.trim().toLowerCase(); const sorted = [...calibreBooks].sort((a, b) => a.title.localeCompare(b.title)); const filtered = q ? sorted.filter(b => b.title.toLowerCase().includes(q) || (b.authors || "").toLowerCase().includes(q) ) : sorted; if (filtered.length === 0) { bookSearchResults.innerHTML = `
No books found
`; } else { bookSearchResults.innerHTML = filtered.slice(0, 40).map(b => { const sel = selectedBookIds.has(String(b.id)); return `
${sel ? "✓" : ""}
${b.title} ${b.authors ? `${b.authors}` : ""}
`; }).join(""); } bookSearchResults.classList.add("open"); } if (bookSearchInput) { bookSearchInput.addEventListener("input", () => { if (calibreBooks.length === 0) return; renderBookResults(bookSearchInput.value); }); bookSearchInput.addEventListener("focus", () => { if (calibreBooks.length === 0) return; renderBookResults(bookSearchInput.value); }); bookSearchResults.addEventListener("mousedown", e => { const item = e.target.closest(".book-result-item"); if (!item) return; e.preventDefault(); // prevent input blur before we register click const id = String(item.dataset.id); if (selectedBookIds.has(id)) { selectedBookIds.delete(id); } else { selectedBookIds.add(id); } renderSelectedChips(); renderBookResults(bookSearchInput.value); }); } if (selectedChipsWrap) { selectedChipsWrap.addEventListener("click", e => { const btn = e.target.closest("button[data-id]"); if (!btn) return; selectedBookIds.delete(String(btn.dataset.id)); renderSelectedChips(); // Refresh dropdown if open if (bookSearchResults.classList.contains("open")) { renderBookResults(bookSearchInput.value); } }); } // Close dropdown on outside click document.addEventListener("click", e => { if (!e.target.closest(".book-search-wrap")) { bookSearchResults.classList.remove("open"); } }); function populateBookSelect() { // no-op — book search handles population dynamically } function openMotalahModal() { motalahForm.reset(); motalahDateEl.value = isoDate(); selectedBookIds = new Set(); bookSearchInput.value = ""; bookSearchResults.classList.remove("open"); bookSearchResults.innerHTML = ""; renderSelectedChips(); motalahModal.showModal(); } if (motalahForm) { motalahForm.addEventListener("submit", async e => { e.preventDefault(); const selectedBooks = [...selectedBookIds]; const payload = { date: motalahDateEl.value, durationMinutes: parseInt(motalahDurEl.value), bookIds: selectedBooks, notes: motalahNotesEl.value.trim() || null, }; try { const r = await fetch(MOTALAH_API, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!r.ok) throw new Error(await r.text()); const saved = await r.json(); motalahEntries.unshift(saved); motalahModal.close(); renderMotalah(); } catch (err) { alert("Error saving session: " + err.message); } }); } $("#motalah-cancel").addEventListener("click", () => motalahModal.close()); $("#log-motalah-btn").addEventListener("click", openMotalahModal); // ── State ──────────────────────────────────────────────── let allEntries = []; let todayMap = {}; let meetingLog = []; let chart = null; // ── Boot ───────────────────────────────────────────────── document.addEventListener("DOMContentLoaded", async () => { setTodayLabel(); await Promise.all([loadAll(), loadMotalah()]); renderToday(); renderNafl(); renderMeetingPanel(); renderKhatm(); renderHistory(); renderChart(); renderMotalah(); renderSummary(); renderHeatmap(); 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; 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(); } async function deleteEntry(id) { const r = await fetch(`${API}/entries/${id}`, { method: "DELETE" }); if (!r.ok) throw new Error(await r.text()); } // ════════════════════════════════════════════════════════ // TODAY PANEL // ════════════════════════════════════════════════════════ 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) { 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}
`; } // ════════════════════════════════════════════════════════ // LOG MODAL (existing — unchanged logic) // ════════════════════════════════════════════════════════ 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"); 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 => { b.classList.toggle("selected", parseInt(b.dataset.val) <= 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}`; } } if (typeEl){ typeEl.addEventListener("change", updateModalFields); } $("#modal-cancel").addEventListener("click", () => modal.close()); if (form){ 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(); renderNafl(); renderMeetingPanel(); renderHistory(); renderChart(); renderHeatmap(); modal.close(); } catch (err) { alert("Could not save entry: " + err.message); } }); } // ════════════════════════════════════════════════════════ // NAFL PRAYERS // Stored in wird_entries: wirdType = "salatul_tawbah" | // "salatul_hajaat" | "tahajjud", value = 1 when prayed. // Toggle = delete existing today entry, or post new one. // ════════════════════════════════════════════════════════ function renderNafl() { const today = isoDate(); // Build a map of which nafl are done today and their entry id const naflToday = {}; // { wirdType: { id, done: true } } for (const e of allEntries) { if (e.date !== today) continue; if (!NAFL_WIRD[e.wirdType]) continue; if (Number(e.value) >= 1) naflToday[e.wirdType] = e.id; } const done = Object.keys(naflToday).length; const total = Object.keys(NAFL_WIRD).length; // Render each card for (const [type, meta] of Object.entries(NAFL_WIRD)) { const card = document.getElementById(`nafl-${type}`); if (!card) continue; const isDone = !!naflToday[type]; card.classList.toggle("nafl-done", isDone); const btn = card.querySelector(".nafl-toggle"); btn.textContent = isDone ? "✓ prayed" : "Mark as prayed"; btn.dataset.type = type; btn.dataset.entryId = naflToday[type] || ""; } // Progress bar — track width = 120px for 3/3 const barPx = Math.round((done / total) * 120); $("#nafl-completion-bar").style.width = barPx + "px"; $("#nafl-completion-label").textContent = `${done} / ${total} today`; } // Single delegated listener on the nafl-cards container $("#nafl-cards").addEventListener("click", async e => { const btn = e.target.closest(".nafl-toggle"); if (!btn) return; const type = btn.dataset.type; const entryId = btn.dataset.entryId; const today = isoDate(); btn.disabled = true; try { if (entryId) { // Toggle OFF — delete await deleteEntry(entryId); allEntries = allEntries.filter(en => String(en.id) !== String(entryId)); } else { // Toggle ON — post const saved = await postEntry({ wirdType: type, date: today, value: 1, notes: null }); allEntries.push(saved); } buildTodayMap(); renderNafl(); renderHeatmap(); } catch (err) { alert("Could not update: " + err.message); } finally { btn.disabled = false; } }); // ════════════════════════════════════════════════════════ // MEETING PANEL (unchanged) // ════════════════════════════════════════════════════════ 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(); } // ════════════════════════════════════════════════════════ // KHATM TRACKER // Stored in wird_entries: wirdType="khatm", value=1. // notes field carries JSON: {"type":"tilawah"} or plain text. // ════════════════════════════════════════════════════════ function getKhatmEntries() { return [...allEntries] .filter(e => e.wirdType === "khatm" && Number(e.value) >= 1) .sort((a, b) => a.date.localeCompare(b.date)); // asc for numbering } function renderKhatm() { const entries = getKhatmEntries(); const total = entries.length; const thisYear = entries.filter( e => e.date.startsWith(String(new Date().getFullYear())) ).length; let avgDays = "—"; if (entries.length >= 2) { let gap = 0; for (let i = 1; i < entries.length; i++) gap += daysBetween(entries[i - 1].date, entries[i].date); avgDays = Math.round(gap / (entries.length - 1)); } const last = entries[entries.length - 1]; $("#khatm-total").textContent = total || "0"; $("#khatm-this-year").textContent = thisYear || "0"; $("#khatm-avg-days").textContent = avgDays; $("#khatm-last").textContent = last ? fmtDisplay(last.date) : "—"; const listEl = $("#khatm-list"); if (entries.length === 0) { listEl.innerHTML = `

No completions logged yet.

`; return; } // Render newest first const display = [...entries].reverse(); listEl.innerHTML = display.map(e => { const num = entries.findIndex(x => x.id === e.id) + 1; const milestone = num % 10 === 0; const prevEntry = entries[num - 2]; // ascending, so num-2 is previous const gapTxt = prevEntry ? `+${daysBetween(prevEntry.date, e.date)}d` : "first"; // Parse recitation type out of notes if stored as JSON let recType = "", noteText = e.notes || ""; try { const parsed = JSON.parse(e.notes); recType = parsed.type || ""; noteText = parsed.notes || ""; } catch (_) { /* notes is plain text */ } const typeBadge = recType ? `${KHATM_TYPES[recType] || recType}` : ""; const note = noteText ? `${noteText}` : ""; return `
${num}
${fmtDisplay(e.date)} ${typeBadge} ${gapTxt} ${note}
`; }).join(""); } // Khatm modal const khatmModal = $("#khatm-modal"); const khatmForm = $("#khatm-form"); const khatmDateEl = $("#khatm-date"); const khatmTypeEl = $("#khatm-recitation"); const khatmNotesEl = $("#khatm-notes"); $("#log-khatm-btn").addEventListener("click", () => { khatmForm.reset(); khatmDateEl.value = isoDate(); khatmModal.showModal(); }); $("#khatm-cancel").addEventListener("click", () => khatmModal.close()); khatmForm.addEventListener("submit", async e => { e.preventDefault(); // Pack recitation type + notes into the notes field as JSON const recType = khatmTypeEl.value || ""; const noteText = khatmNotesEl.value.trim(); const notes = (recType || noteText) ? JSON.stringify({ type: recType || undefined, notes: noteText || undefined }) : null; try { const saved = await postEntry({ wirdType: "khatm", date: khatmDateEl.value, value: 1, notes, }); allEntries.push(saved); khatmModal.close(); renderKhatm(); renderHeatmap(); } catch (err) { alert("Error saving khatm: " + err.message); } }); // ════════════════════════════════════════════════════════ // HISTORY TABLE (filter out nafl + khatm to keep it clean, // or you can remove the filter if you want them shown) // ════════════════════════════════════════════════════════ function renderHistory() { const tbody = $("#history-body"); tbody.innerHTML = ""; const HIDE_TYPES = new Set(["shaykh_meeting", ...Object.keys(NAFL_WIRD), "khatm"]); const sorted = [...allEntries] .filter(e => !HIDE_TYPES.has(e.wirdType)) .sort((a, b) => b.date.localeCompare(a.date)) .slice(0, 20); 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" ? "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); } } // ════════════════════════════════════════════════════════ // TREND CHART (unchanged) // ════════════════════════════════════════════════════════ 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 = []; const isMotalah = type === "motalah"; 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)); if (isMotalah) { vals.push(motalahEntries.filter(e => e.date === iso) .reduce((s, e) => s + Number(e.durationMinutes), 0)); } else { const dayEntries = allEntries.filter(e => e.date === iso && e.wirdType === type); if (isRating) { 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 ≥ ${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 }, ...(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); let total; if (type === "motalah") { total = motalahEntries.filter(e => e.date === iso) .reduce((s, e) => s + Number(e.durationMinutes), 0); } else { const entries = allEntries.filter(e => e.date === iso && e.wirdType === type); if (meta && 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); } } const target = type === "motalah" ? MOTALAH_TARGET : (meta ? meta.target : 1); if (total >= target) streak++; else if (i > 0) break; } return streak; } // ════════════════════════════════════════════════════════ // HEATMAP // Reads allEntries directly. No extra API call. // ════════════════════════════════════════════════════════ function renderHeatmap() { const type = $("#heatmap-type").value; const today = new Date(); const todayIso = isoDate(today); // Anchor: Sunday on or before today, then go back (HEATMAP_WEEKS-1) full weeks const anchor = new Date(today); anchor.setDate(anchor.getDate() - anchor.getDay()); const startDate = new Date(anchor); startDate.setDate(startDate.getDate() - (HEATMAP_WEEKS - 1) * 7); const scoreMap = buildHeatmapScores(type, isoDate(startDate), todayIso); // ── Month labels ────────────────────────────────────── const monthLabelEl = $("#heatmap-month-labels"); monthLabelEl.innerHTML = ""; let lastMonth = null; // 15px per col (12px cell + 3px gap); labels are position:absolute for (let w = 0; w < HEATMAP_WEEKS; w++) { const d = new Date(startDate); d.setDate(d.getDate() + w * 7); const mo = d.toLocaleDateString("en-GB", { month: "short" }); if (mo !== lastMonth) { const lbl = document.createElement("span"); lbl.className = "hm-month-lbl"; lbl.style.left = w * 15 + "px"; lbl.textContent = mo; monthLabelEl.appendChild(lbl); lastMonth = mo; } } // ── Grid ───────────────────────────────────────────── const gridEl = $("#heatmap-grid"); gridEl.innerHTML = ""; // Day-of-week label column const dowCol = document.createElement("div"); dowCol.className = "hm-dow-col"; ["", "Mon", "", "Wed", "", "Fri", ""].forEach(lbl => { const el = document.createElement("div"); el.className = "hm-dow-lbl"; el.textContent = lbl; dowCol.appendChild(el); }); gridEl.appendChild(dowCol); for (let w = 0; w < HEATMAP_WEEKS; w++) { const col = document.createElement("div"); col.className = "hm-col"; for (let d = 0; d < 7; d++) { const cellDate = new Date(startDate); cellDate.setDate(cellDate.getDate() + w * 7 + d); const iso = isoDate(cellDate); const cell = document.createElement("div"); cell.className = "hm-cell"; if (iso > todayIso) { cell.classList.add("hm-empty"); } else { const score = scoreMap[iso] || 0; cell.dataset.level = scoreToLevel(score, type); if (iso === todayIso) cell.classList.add("hm-today"); cell.title = buildTooltip(iso, score, type); } col.appendChild(cell); } gridEl.appendChild(col); } // ── Stats ───────────────────────────────────────────── renderHeatmapStats(scoreMap, type, isoDate(startDate), todayIso); } function buildHeatmapScores(type, startIso, endIso) { // Returns { "2025-01-14": score } where score is a 0–1+ fraction // (against the relevant target) so scoreToLevel() is consistent. const map = {}; if (type === "__all__") { // Fraction of DAILY_WIRD targets met each day const dayCounts = {}; // { date: { type: total } } for (const e of allEntries) { if (e.date < startIso || e.date > endIso) continue; if (!DAILY_WIRD[e.wirdType]) continue; if (!dayCounts[e.date]) dayCounts[e.date] = {}; const meta = DAILY_WIRD[e.wirdType]; if (meta.type === "rating") { dayCounts[e.date][e.wirdType] = Math.max( dayCounts[e.date][e.wirdType] || 0, Number(e.value) ); } else { dayCounts[e.date][e.wirdType] = (dayCounts[e.date][e.wirdType] || 0) + Number(e.value); } } const total = Object.keys(DAILY_WIRD).length; for (const [date, typeMap] of Object.entries(dayCounts)) { let met = 0; for (const [t, val] of Object.entries(typeMap)) { if (val >= DAILY_WIRD[t].target) met++; } map[date] = met / total; } return map; } // Nafl: fraction of 3 prayers done if (type === "nafl") { for (const e of allEntries) { if (!NAFL_WIRD[e.wirdType]) continue; if (e.date < startIso || e.date > endIso) continue; if (Number(e.value) >= 1) map[e.date] = (map[e.date] || 0) + (1 / 3); } return map; } // Khatm: binary if (type === "khatm") { for (const e of allEntries) { if (e.wirdType !== "khatm") continue; if (e.date < startIso || e.date > endIso) continue; if (Number(e.value) >= 1) map[e.date] = 1; } return map; } // Motalah: use motalahEntries, normalised against MOTALAH_TARGET if (type === "motalah") { for (const e of motalahEntries) { if (e.date < startIso || e.date > endIso) continue; map[e.date] = (map[e.date] || 0) + Number(e.durationMinutes); } for (const d of Object.keys(map)) map[d] = map[d] / MOTALAH_TARGET; return map; } // Single wird type — normalised against target const meta = WIRD_META[type]; for (const e of allEntries) { if (e.wirdType !== type) continue; if (e.date < startIso || e.date > endIso) continue; if (meta && meta.type === "rating") { map[e.date] = Math.max(map[e.date] || 0, Number(e.value)); } else { map[e.date] = (map[e.date] || 0) + Number(e.value); } } if (meta && meta.target) { for (const d of Object.keys(map)) map[d] = map[d] / meta.target; } return map; } function scoreToLevel(score, type) { if (type === "khatm") return score > 0 ? 4 : 0; if (score <= 0) return 0; if (score < 0.4) return 1; if (score < 0.75) return 2; if (score < 1.0) return 3; return 4; // met or exceeded } function buildTooltip(iso, score, type) { const label = fmtDisplay(iso); if (score <= 0) return `${label}: nothing logged`; if (type === "__all__") return `${label}: ${Math.round(score * 100)}% of targets met`; if (type === "nafl") return `${label}: ${Math.round(score * 3)} / 3 prayers`; if (type === "khatm") return `${label}: khatm completed ✓`; if (type === "motalah") return `${label}: ${Math.round(score * MOTALAH_TARGET)} min`; const meta = WIRD_META[type]; if (!meta) return label; if (meta.type === "rating") { const r = Math.round(score); return `${label}: ${RATING_LABELS[r] || r}/5`; } const raw = score * (meta.target || 1); return `${label}: ${Math.round(raw).toLocaleString()} ${meta.unit}`; } // ============================================================ // DAILY SUMMARY TABLE // ============================================================ let summaryDays = 30; let summaryFilter = "__all__"; function renderSummary() { const wrap = document.getElementById("summary-table-wrap"); if (!wrap) return; const today = isoDate(); const cols = summaryFilter === "__all__" ? Object.keys(DAILY_WIRD) : [summaryFilter]; // Build date rows newest→oldest const rows = []; for (let i = 0; i < summaryDays; i++) { const d = new Date(); d.setDate(d.getDate() - i); rows.push(isoDate(d)); } // Pre-aggregate entries into { date: { type: val } } const agg = {}; for (const e of allEntries) { if (!cols.includes(e.wirdType)) continue; if (!agg[e.date]) agg[e.date] = {}; const meta = DAILY_WIRD[e.wirdType]; if (meta && meta.type === "rating") { agg[e.date][e.wirdType] = Math.max( agg[e.date][e.wirdType] || 0, Number(e.value) ); } else { agg[e.date][e.wirdType] = (agg[e.date][e.wirdType] || 0) + Number(e.value); } } // Build header const thCols = cols.map(t => { const meta = DAILY_WIRD[t]; const unitHint = meta.type === "rating" ? "/5" : meta.unit ? meta.unit : ""; return ` ${meta.label}
/ ${meta.target}${unitHint} `; }).join(""); // Build body rows const bodyRows = rows.map(dateIso => { const isToday = dateIso === today; const dayData = agg[dateIso] || {}; const displayDate = new Date(dateIso + "T00:00:00").toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }); const cells = cols.map(type => { const meta = DAILY_WIRD[type]; const val = dayData[type]; if (val === undefined || val === null) { return `—`; } const cls = statusClass(type, val); const cellCls = cls === "over" ? "summary-cell-over" : cls === "done" ? "summary-cell-done" : cls === "miss" ? "summary-cell-miss" : ""; let display; if (meta.type === "rating") { display = `${val}/5`; } else { display = Number(val).toLocaleString(); } return `${display}`; }).join(""); return ` ${displayDate}${isToday ? " ·" : ""} ${cells} `; }).join(""); // Footer: days target met per column const footCells = cols.map(type => { const meta = DAILY_WIRD[type]; let metCount = 0; for (const dateIso of rows) { const val = (agg[dateIso] || {})[type]; if (val !== undefined && val >= meta.target) metCount++; } const pct = Math.round((metCount / summaryDays) * 100); const cls = pct >= 80 ? "summary-cell-done" : pct >= 50 ? "" : "summary-cell-miss"; return `${metCount}d (${pct}%)`; }).join(""); wrap.innerHTML = ` ${thCols} ${bodyRows} ${footCells}
Date
days met
`; } // Controls document.querySelectorAll(".summary-preset").forEach(btn => { btn.addEventListener("click", () => { summaryDays = parseInt(btn.dataset.days); document.querySelectorAll(".summary-preset").forEach(b => b.classList.remove("active")); btn.classList.add("active"); renderSummary(); }); }); const summaryFilterEl = document.getElementById("summary-filter"); if (summaryFilterEl) { summaryFilterEl.addEventListener("change", () => { summaryFilter = summaryFilterEl.value; renderSummary(); }); } function renderHeatmapStats(scoreMap, type, startIso, endIso) { const el = $("#heatmap-stats"); const daysLogged = Object.values(scoreMap).filter(v => v > 0).length; const daysTarget = Object.values(scoreMap).filter(v => v >= 1).length; // Longest streak of any-activity let longest = 0, cur = 0; const s = new Date(startIso), e = new Date(endIso); for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) { if ((scoreMap[isoDate(d)] || 0) > 0) { cur++; longest = Math.max(longest, cur); } else cur = 0; } const chips = [ { val: daysLogged, lbl: "days with activity" }, { val: `${longest}d`, lbl: "longest streak" }, ]; if (type !== "khatm") { chips.push({ val: daysTarget, lbl: type === "nafl" ? "days all prayers done" : "days target met" }); } el.innerHTML = chips.map(c => `
${c.val} ${c.lbl}
` ).join(""); } // ════════════════════════════════════════════════════════ // BIND CONTROLS // ════════════════════════════════════════════════════════ function bindControls() { $("#open-log-btn").addEventListener("click", () => openModal()); $("#log-meeting-btn").addEventListener("click", openMeetingModal); $("#trend-type").addEventListener("change", renderChart); $("#trend-range").addEventListener("change", renderChart); $("#heatmap-type").addEventListener("change", renderHeatmap); } /* ============================================================ wird-export.js Client-side PDF export for the Wird Tracker. Depends on: jsPDF + jspdf-autotable (loaded via CDN). All data is read from allEntries / motalahEntries already held in memory by wird-tracker.js — no extra API calls. HOW TO INTEGRATE ───────────────── 1. Paste this block INSIDE the existing IIFE in wird-tracker.js, just before the closing })(); 2. Add the HTML snippet (wird-export-html.html) to the template and the CDN