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.
+
+
+
+
+
+
+
🤲
+
Ṣalāt al-Tawbah
+
2 rakʿāt · repentance
+
+
+
+
+
🌙
+
Ṣalāt al-Ḥājah
+
2 rakʿāt · need & supplication
+
+
+
+
+
⭐
+
Tahajjud
+
night vigil prayer
+
+
+
+
+
+
+
+
+
+
+
+
+ —
+ total khatms
+
+
+ —
+ this year
+
+
+ —
+ avg days between
+
+
+ —
+ last completed
+
+
+
+
+
+
+
+