From 73ba1b3eb542017936b6bf921ef1470eba6b2e80 Mon Sep 17 00:00:00 2001 From: Zaine Arch Date: Sat, 21 Mar 2026 17:38:04 +0000 Subject: [PATCH] updating wird frontend --- assets/scripts/wird-tracker.js | 825 ++++++++++++++++++++++++++++++--- assets/styles/wird-tracker.css | Bin 15974 -> 28378 bytes home/recently-updated.org | 2 +- home/wird-tracker.org | 325 ++++++++++--- org-web-build.log | 10 +- posts/posts-list.org | 2 +- sitemap.org | 2 +- 7 files changed, 1024 insertions(+), 142 deletions(-) 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 3a0a196cbe5833deabd8ac3621b95f8135d1c06f..5caccefaba33a3f80bb824a562fde08da76e363f 100644 GIT binary patch literal 28378 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 G`t*O`ZepMS delta 1249 zcmb7DO=}ZD7~b8+l!l^0)7Yd*^V)CQ=CesmQz}LTwMDdAsa}LhcA6~R>~3ebX=@dF zS44Teh@f~BJSck>>_tKUfLKLPQIDQIIlJva6hkr#JM8T9Jnz>t?>~y)uHU&N`tL5h zmIMKN#oj)l;j(k$U|T^DplBFiR;j8&L;T-Ak$Pqw`o$_?R0e}+v=s&Wg(o37`~_Vm z8&IW0rmTew>|fq3m%pem8OnsoCRCUrH!ES^?AY|W@fp>)fUa3gSIuVWf*m+G?6&W; zVp=-e0E?7V+B99T=eo8f(QkJKCfqX4Go?njOlj4!bPfDaBUZUeO)wRUf}!y7nAXNW z5p2?PQgq5V%iGvW|ApO!PlH9=@ySzE7ps)WXmY}N z;^`yPKH$l%$JP|tsuo}}&mqp)-uG0xzJ`xOG5j1#_D$wpc!`vkWu_akB}KI;D?mwI zWus|)+Z&BQ5>oM0mIrr4miQcErgDuIAm!x4!bJ>5PDtrI{yY_Ei|4agkBs9^u>-Ah zA^ckG#&?laTaH7a?*~4bZ4VekmKCj1fOsa&B_N*7yOqt2;-}z#UTgqwT~6b@XcoUj z?_;6UXMcblNgTlN;PGUI7@XZ=mM%I#woHnX0(blg8T6 z70d(&@cYmVK0DySnc*%8HgR6=#&}nI5jZI^#=QX-sA=e$snlC=3Lg}{9DeBB>Z>Dx z{=LC9*lcU$O}hg>mI&t>-~<>;xQU(7R7IoiCE`vqOz_(*v9OQ3^Ht`FG{y5?TwPgS vf_b8r#9=J<22SDCOm*ue<$xTJrn`tli{* 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:@@ +- [[file:home/wird-tracker.org][Wird Tracker]] @@html:@@ - [[file:blogs/2026/03-march/setting-a-wird-tracker-19-03.org][Setting a Wird Tracker]] @@html:@@ - [[file:home/guide/wird-tracker-guide.org][Wird Tracker — Technical Guide]] @@html:@@ - [[file:blogs/2026/03-march/fixing-the-dag-18-03.org][DAG fixes]] @@html:@@ 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 +
+
+
+
+