diff --git a/assets/scripts/wird-tracker.js b/assets/scripts/wird-tracker.js index 68dcb01..7e387db 100644 --- a/assets/scripts/wird-tracker.js +++ b/assets/scripts/wird-tracker.js @@ -1,5 +1,5 @@ /* ============================================================ - wird-tracker.js + wird-tracker.js (full replacement) ============================================================ */ (function () { @@ -7,30 +7,42 @@ const API = "/api/wird"; - - // type: "count" | "juz" | "min" | "rating" + // type: "count" | "juz" | "min" | "rating" | "nafl" | "khatm" 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 }, + durood: { label: "Durood", type: "count", unit: "count", target: 500 }, + istighfar: { label: "Istighfar", type: "count", unit: "count", target: 200 }, + quran: { label: "Qurʾān", type: "juz", unit: "juz", target: 3 }, + muraqabah: { label: "Murāqabah", type: "min", unit: "min", target: 10 }, + wuqoof_qalbi: { label: "Wuqūf Qalbī", type: "rating", unit: "", target: 3 }, + }; + + // 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, - shaykh_meeting: { label: "Meeting w/ Shaykh", type: "meeting", unit: "meeting", target: 1 }, + ...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", + 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)]; @@ -53,6 +65,8 @@ 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}`; } @@ -60,7 +74,7 @@ const meta = WIRD_META[type]; if (!meta || !val || val <= 0) return ""; if (meta.type === "rating") { - if (val >= 4) return "over"; + if (val >= 4) return "over"; if (val >= 3) return "done"; return "miss"; } @@ -69,18 +83,255 @@ 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"); + } + + 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); + }); + + 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(); + } + + 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 loadAll(); + await Promise.all([loadAll(), loadMotalah()]); // ← change this line renderToday(); + renderNafl(); renderMeetingPanel(); + renderKhatm(); renderHistory(); renderChart(); + renderMotalah(); // ← add this + renderHeatmap(); bindControls(); }); @@ -107,7 +358,6 @@ 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) { @@ -135,6 +385,15 @@ 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 = ""; @@ -178,16 +437,13 @@ } 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; + 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}
@@ -196,6 +452,10 @@ `; } + // ════════════════════════════════════════════════════════ + // LOG MODAL (existing — unchanged logic) + // ════════════════════════════════════════════════════════ + const modal = $("#log-modal"); const form = $("#log-form"); const typeEl = $("#log-type"); @@ -204,15 +464,13 @@ 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); + b.classList.toggle("selected", parseInt(b.dataset.val) <= selectedRating); }); }); }); @@ -240,11 +498,9 @@ 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}`; @@ -260,17 +516,12 @@ const isRating = type === "wuqoof_qalbi"; const isMtg = type === "shaykh_meeting"; - if (isRating && !ratingEl.value) { - alert("Please select a rating."); - return; - } + 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), + value: isRating ? Number(ratingEl.value) : isMtg ? 1 : Number(valEl.value), notes: notesEl.value.trim() || null, }; @@ -280,15 +531,90 @@ 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; @@ -367,12 +693,133 @@ 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 => e.wirdType !== "shaykh_meeting") + .filter(e => !HIDE_TYPES.has(e.wirdType)) .sort((a, b) => b.date.localeCompare(a.date)); if (sorted.length === 0) { @@ -381,11 +828,11 @@ } for (const e of sorted) { - const meta = WIRD_META[e.wirdType] || {}; - const cls = statusClass(e.wirdType, e.value); + 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") + : cls === "done" ? (meta.type === "rating" ? "present" : "met") + : cls === "miss" ? "below" : "—"; const tr = document.createElement("tr"); tr.innerHTML = ` @@ -399,29 +846,36 @@ } } + // ════════════════════════════════════════════════════════ + // TREND CHART (unchanged) + // ════════════════════════════════════════════════════════ + function renderChart() { - const type = $("#trend-type").value; - const days = parseInt($("#trend-range").value); - const meta = WIRD_META[type]; + 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 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)); - - 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); + if (isMotalah) { + vals.push(motalahEntries.filter(e => e.date === iso) + .reduce((s, e) => s + Number(e.durationMinutes), 0)); } else { - vals.push(dayEntries.reduce((s, e) => s + Number(e.value), 0)); + 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)); + } } } @@ -431,14 +885,14 @@ ? (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); + 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}
+
${streak}dstreak ≥ ${meta.target}
${nonZero.length}days logged
`; } else { @@ -454,8 +908,7 @@ 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"); + const ctx = $("#trend-chart").getContext("2d"); if (chart) chart.destroy(); chart = new Chart(ctx, { @@ -468,19 +921,14 @@ data: vals, borderColor: accent, backgroundColor: accent + "22", - fill: true, - tension: .35, - pointRadius: 3, - pointHoverRadius: 5, + 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, + pointRadius: 0, fill: false, tension: 0, }, ], }, @@ -510,7 +958,6 @@ 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), @@ -529,29 +976,255 @@ let streak = 0; const now = new Date(); for (let i = 0; i < 365; i++) { - const d = new Date(now); + 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); + const iso = isoDate(d); 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; + if (type === "motalah") { + total = motalahEntries.filter(e => e.date === iso) + .reduce((s, e) => s + Number(e.durationMinutes), 0); } else { - total = entries.reduce((s, e) => s + Number(e.value), 0); + 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); + } } - if (total >= meta.target) streak++; + 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) + let colsPassed = 0; + 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.paddingLeft = (w - colsPassed) * 15 + "px"; + lbl.textContent = mo; + monthLabelEl.appendChild(lbl); + colsPassed = w; + 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}`; + } + + 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); } -})(); - +})(); \ No newline at end of file diff --git a/assets/styles/wird-tracker.css b/assets/styles/wird-tracker.css index 3a0a196..5caccef 100644 Binary files a/assets/styles/wird-tracker.css and b/assets/styles/wird-tracker.css differ diff --git a/home/recently-updated.org b/home/recently-updated.org index 59669a7..b5c76c0 100755 --- a/home/recently-updated.org +++ b/home/recently-updated.org @@ -2,7 +2,7 @@ #+OPTIONS: toc:nil num:nil * Recently Updated (top 26 files) -- [[file:home/wird-tracker.org][Wird Tracker]] @@html:2026-03-19 18:26@@ +- [[file:home/wird-tracker.org][Wird Tracker]] @@html:2026-03-21 17:34@@ - [[file:blogs/2026/03-march/setting-a-wird-tracker-19-03.org][Setting a Wird Tracker]] @@html:2026-03-19 13:15@@ - [[file:home/guide/wird-tracker-guide.org][Wird Tracker — Technical Guide]] @@html:2026-03-19 12:43@@ - [[file:blogs/2026/03-march/fixing-the-dag-18-03.org][DAG fixes]] @@html:2026-03-18 15:01@@ diff --git a/home/wird-tracker.org b/home/wird-tracker.org index e9a99dd..b537b38 100644 --- a/home/wird-tracker.org +++ b/home/wird-tracker.org @@ -22,6 +22,43 @@ This page tracks my daily awrād and spiritual practices.
+ +
+
+

Nafl Prayers

+
+
+ +
+
🤲
+
Ṣalāt al-Tawbah
+
2 rakʿāt · repentance
+ +
+ +
+
🌙
+
Ṣalāt al-Ḥājah
+
2 rakʿāt · need & supplication
+ +
+ +
+
+
Tahajjud
+
night vigil prayer
+ +
+ +
+
+
+
+
+ 0 / 3 today +
+
+
@@ -33,72 +70,137 @@ This page tracks my daily awrād and spiritual practices.
+ +
+
+

Khatm — Qurʾān Completions

+ +
+
+
+ + total khatms +
+
+ + this year +
+
+ + avg days between +
+
+ + last completed +
+
+
+

Loading…

+
+
+ + + +
+

Log Khatm

+ +
+ + +
+
+
+
+
+ +
+
+

Mutālaʿah — Study

+ +
+ + +
+
+ + min today +
+
+ + day streak +
+
+ + hrs this month +
+
+ + books touched +
+
+ + +
+

Loading…

+
+
+ + + +
+

Log Study Session

+ +
+ + +
+
+
+ + +
+
+

Consistency

+ +
+
+
+
+
+ less + + + + + + more +
+
+
+
+