/* ============================================================
wird-tracker.js (full replacement)
============================================================ */
(function () {
"use strict";
if (!document.getElementById("wird-app")) return;
const API = "/api/wird";
// type: "count" | "juz" | "min" | "rating" | "nafl" | "khatm"
const DAILY_WIRD = {
durood: { label: "Durood", type: "count", unit: "count", target: 300 },
istighfar: { label: "Istighfar", type: "count", unit: "count", target: 200 },
quran: { label: "Qurʾān", type: "juz", unit: "juz", target: 2 },
muraqabah: { label: "Murāqabah", type: "min", unit: "min", target: 10 },
wuqoof_qalbi: { label: "Wuqūf Qalbī", type: "rating", unit: "", target: 3 },
Allah: { label: "Allah's Name", type: "count", unit: "count", target: 500 },
};
// Nafl prayers — stored in wird_entries, value=1 when done
const NAFL_WIRD = {
salatul_tawbah: { label: "Ṣalāt al-Tawbah", type: "nafl", unit: "", target: 1, rakats: 2, desc: "2 rakʿāt · repentance", icon: "🤲" },
salatul_hajaat: { label: "Ṣalāt al-Ḥājah", type: "nafl", unit: "", target: 1, rakats: 2, desc: "2 rakʿāt · need & supplication", icon: "🌙" },
tahajjud: { label: "Tahajjud", type: "nafl", unit: "", target: 1, rakats: 0, desc: "night vigil", icon: "⭐" },
};
const WIRD_META = {
...DAILY_WIRD,
...NAFL_WIRD,
shaykh_meeting: { label: "Meeting w/ Shaykh", type: "meeting", unit: "meeting", target: 1 },
khatm: { label: "Khatm", type: "khatm", unit: "", target: 1 },
motalah: { label: "Mutālaʿah", type: "motalah", unit: "min", target: 30 },
};
const RATING_LABELS = {
1: "distracted", 2: "scattered", 3: "present", 4: "attentive", 5: "absorbed",
};
const KHATM_TYPES = {
tilawah: "Tilāwah",
hifz: "Ḥifẓ",
tadabbur: "Tadabbur",
};
const MEETING_CYCLE_DAYS = 21;
const HEATMAP_WEEKS = 52;
const $ = (sel, ctx = document) => ctx.querySelector(sel);
const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)];
const isoDate = (d = new Date()) => d.toISOString().slice(0, 10);
function daysBetween(a, b) {
return Math.round((new Date(b) - new Date(a)) / 86400000);
}
function addDays(iso, n) {
const d = new Date(iso); d.setDate(d.getDate() + n); return isoDate(d);
}
function fmtDisplay(iso) {
return new Date(iso + "T00:00:00").toLocaleDateString("en-GB", {
day: "numeric", month: "short", year: "numeric",
});
}
function fmtValue(type, val) {
const meta = WIRD_META[type];
if (!meta) return val;
if (meta.type === "rating") return val ? `${RATING_LABELS[val] || val} (${val}/5)` : "—";
if (meta.type === "meeting") return val >= 1 ? "✓ attended" : "✗ missed";
if (meta.type === "nafl") return val >= 1 ? "✓ prayed" : "—";
if (meta.type === "khatm") return val >= 1 ? "✓ completed" : "—";
return `${Number(val).toLocaleString()} ${meta.unit}`;
}
function statusClass(type, val) {
const meta = WIRD_META[type];
if (!meta || !val || val <= 0) return "";
if (meta.type === "rating") {
if (val >= 4) return "over";
if (val >= 3) return "done";
return "miss";
}
if (val >= meta.target * 1.1) return "over";
if (val >= meta.target) return "done";
return "miss";
}
// ── Mutālaʿah (Study) ─────────────────────────────────────
const MOTALAH_API = "/api/wird/motalah";
const CALIBRE_API = "/api/calibre/books"; // see controller below
const MOTALAH_TARGET = 30; // daily target in minutes
let motalahEntries = [];
let calibreBooks = [];
async function loadMotalah() {
try {
const [mRes, bRes] = await Promise.all([
fetch(MOTALAH_API),
fetch(CALIBRE_API),
]);
if (mRes.ok) motalahEntries = await mRes.json();
if (bRes.ok) calibreBooks = await bRes.json();
} catch (e) {
console.warn("Mutalaah load error:", e);
}
}
function renderMotalah() {
const today = isoDate();
const todayM = motalahEntries.filter(e => e.date === today);
const todayMins = todayM.reduce((s, e) => s + Number(e.durationMinutes), 0);
// streak
let streak = 0;
const now = new Date();
for (let i = 0; i < 365; i++) {
const d = new Date(now); d.setDate(d.getDate() - i);
const iso = isoDate(d);
const mins = motalahEntries.filter(e => e.date === iso)
.reduce((s, e) => s + Number(e.durationMinutes), 0);
if (mins >= MOTALAH_TARGET) streak++;
else if (i > 0) break;
}
// month total
const monthStart = today.slice(0, 7);
const monthMins = motalahEntries
.filter(e => e.date.startsWith(monthStart))
.reduce((s, e) => s + Number(e.durationMinutes), 0);
const monthHrs = (monthMins / 60).toFixed(1);
// unique books touched (all time)
const allBooks = new Set(
motalahEntries.flatMap(e => e.bookIds || [])
);
$("#motalah-today-mins").textContent = todayMins || "0";
$("#motalah-streak").textContent = streak;
$("#motalah-total-hrs").textContent = monthHrs;
$("#motalah-books-count").textContent = allBooks.size;
// sessions list (20 most recent)
const wrap = $("#motalah-sessions-wrap");
const sorted = [...motalahEntries].sort((a, b) => b.date.localeCompare(a.date)).slice(0, 20);
if (sorted.length === 0) {
wrap.innerHTML = `
No study sessions logged yet.
`;
return;
}
wrap.innerHTML = sorted.map(e => {
const bookNames = (e.bookIds || [])
.map(id => {
const b = calibreBooks.find(b => String(b.id) === String(id));
return b ? `${b.title} ` : "";
}).join("");
const note = e.notes
? ` — ${e.notes} `
: "";
return `
${e.date}
${e.durationMinutes} min
${bookNames || ""}${note}
`;
}).join("");
}
// Modal
const motalahModal = $("#motalah-modal");
const motalahForm = $("#motalah-form");
const motalahDateEl = $("#motalah-date");
const motalahDurEl = $("#motalah-duration");
const motalahBooksEl = $("#motalah-books");
const motalahNotesEl = $("#motalah-notes");
// ── Book search UI ────────────────────────────────────────
// selectedBookIds: Set of ids currently picked in the modal
let selectedBookIds = new Set();
const bookSearchInput = document.getElementById("motalah-book-search");
const bookSearchResults = document.getElementById("book-search-results");
const selectedChipsWrap = document.getElementById("selected-books-chips");
function bookById(id) {
return calibreBooks.find(b => String(b.id) === String(id));
}
function renderSelectedChips() {
selectedChipsWrap.innerHTML = [...selectedBookIds].map(id => {
const b = bookById(id);
const label = b ? b.title : id;
return `
${label}
×
`;
}).join("");
// Sync hidden select so the existing submit handler still works
motalahBooksEl.innerHTML = [...selectedBookIds]
.map(id => ` `).join("");
}
function renderBookResults(query) {
const q = query.trim().toLowerCase();
const sorted = [...calibreBooks].sort((a, b) => a.title.localeCompare(b.title));
const filtered = q
? sorted.filter(b =>
b.title.toLowerCase().includes(q) ||
(b.authors || "").toLowerCase().includes(q)
)
: sorted;
if (filtered.length === 0) {
bookSearchResults.innerHTML = `No books found
`;
} else {
bookSearchResults.innerHTML = filtered.slice(0, 40).map(b => {
const sel = selectedBookIds.has(String(b.id));
return `
${sel ? "✓" : ""}
${b.title}
${b.authors ? `
${b.authors} ` : ""}
`;
}).join("");
}
bookSearchResults.classList.add("open");
}
if (bookSearchInput) {
bookSearchInput.addEventListener("input", () => {
if (calibreBooks.length === 0) return;
renderBookResults(bookSearchInput.value);
});
bookSearchInput.addEventListener("focus", () => {
if (calibreBooks.length === 0) return;
renderBookResults(bookSearchInput.value);
});
bookSearchResults.addEventListener("mousedown", e => {
const item = e.target.closest(".book-result-item");
if (!item) return;
e.preventDefault(); // prevent input blur before we register click
const id = String(item.dataset.id);
if (selectedBookIds.has(id)) {
selectedBookIds.delete(id);
} else {
selectedBookIds.add(id);
}
renderSelectedChips();
renderBookResults(bookSearchInput.value);
});
}
if (selectedChipsWrap) {
selectedChipsWrap.addEventListener("click", e => {
const btn = e.target.closest("button[data-id]");
if (!btn) return;
selectedBookIds.delete(String(btn.dataset.id));
renderSelectedChips();
// Refresh dropdown if open
if (bookSearchResults.classList.contains("open")) {
renderBookResults(bookSearchInput.value);
}
});
}
// Close dropdown on outside click
document.addEventListener("click", e => {
if (!e.target.closest(".book-search-wrap")) {
bookSearchResults.classList.remove("open");
}
});
function populateBookSelect() {
// no-op — book search handles population dynamically
}
function openMotalahModal() {
motalahForm.reset();
motalahDateEl.value = isoDate();
selectedBookIds = new Set();
bookSearchInput.value = "";
bookSearchResults.classList.remove("open");
bookSearchResults.innerHTML = "";
renderSelectedChips();
motalahModal.showModal();
}
if (motalahForm) {
motalahForm.addEventListener("submit", async e => {
e.preventDefault();
const selectedBooks = [...selectedBookIds];
const payload = {
date: motalahDateEl.value,
durationMinutes: parseInt(motalahDurEl.value),
bookIds: selectedBooks,
notes: motalahNotesEl.value.trim() || null,
};
try {
const r = await fetch(MOTALAH_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(await r.text());
const saved = await r.json();
motalahEntries.unshift(saved);
motalahModal.close();
renderMotalah();
} catch (err) {
alert("Error saving session: " + err.message);
}
});
}
$("#motalah-cancel").addEventListener("click", () => motalahModal.close());
$("#log-motalah-btn").addEventListener("click", openMotalahModal);
// ── State ────────────────────────────────────────────────
let allEntries = [];
let todayMap = {};
let meetingLog = [];
let chart = null;
// ── Boot ─────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", async () => {
setTodayLabel();
await Promise.all([loadAll(), loadMotalah()]);
renderToday();
renderNafl();
renderMeetingPanel();
renderKhatm();
renderHistory();
renderChart();
renderMotalah();
renderSummary();
renderHeatmap();
bindControls();
});
function setTodayLabel() {
$("#today-date").textContent = new Date().toLocaleDateString("en-GB", {
weekday: "long", day: "numeric", month: "long", year: "numeric",
});
}
async function loadAll() {
try {
const r = await fetch(`${API}/entries`);
if (!r.ok) throw new Error(r.status);
allEntries = await r.json();
buildTodayMap();
buildMeetingLog();
} catch (e) {
console.error("Wird API error:", e);
}
}
function buildTodayMap() {
const today = isoDate();
todayMap = {};
for (const e of allEntries) {
if (e.date !== today) continue;
if (WIRD_META[e.wirdType]?.type === "rating") {
const existing = todayMap[e.wirdType];
if (!existing || e.createdAt > existing.createdAt) {
todayMap[e.wirdType] = Number(e.value);
}
} else {
todayMap[e.wirdType] = (todayMap[e.wirdType] || 0) + Number(e.value);
}
}
}
function buildMeetingLog() {
meetingLog = allEntries
.filter(e => e.wirdType === "shaykh_meeting" && Number(e.value) >= 1)
.sort((a, b) => b.date.localeCompare(a.date));
}
async function postEntry(payload) {
const r = await fetch(`${API}/entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
}
async function deleteEntry(id) {
const r = await fetch(`${API}/entries/${id}`, { method: "DELETE" });
if (!r.ok) throw new Error(await r.text());
}
// ════════════════════════════════════════════════════════
// TODAY PANEL
// ════════════════════════════════════════════════════════
function renderToday() {
const wrap = $("#wird-cards");
wrap.innerHTML = "";
let done = 0;
const types = Object.keys(DAILY_WIRD);
for (const type of types) {
const meta = DAILY_WIRD[type];
const val = todayMap[type] || 0;
const cls = statusClass(type, val);
if (cls === "done" || cls === "over") done++;
const card = document.createElement("div");
card.className = `wird-card ${cls}`;
card.dataset.type = type;
if (meta.type === "rating") {
card.innerHTML = buildRatingCard(meta, val);
} else {
const pct = Math.min((val / meta.target) * 100, 100);
card.innerHTML = `
${meta.label}
${fmtValue(type, val)}
/ ${meta.target.toLocaleString()} ${meta.unit}
+ log
`;
}
card.querySelector(".card-log-btn").addEventListener("click", () => openModal(type));
wrap.appendChild(card);
}
const pct = Math.round((done / types.length) * 100);
$("#today-progress-fill").style.width = pct + "%";
$("#today-progress-label").textContent = `${done} / ${types.length} complete`;
}
function buildRatingCard(meta, val) {
const pips = [1, 2, 3, 4, 5].map(n => {
const filled = val >= n;
const isMin = n === meta.target;
return ` `;
}).join("");
const lbl = val ? `${RATING_LABELS[val]} (${val}/5) ` : "not logged";
return `
${meta.label}
${pips}
${lbl}
+ log
`;
}
// ════════════════════════════════════════════════════════
// LOG MODAL (existing — unchanged logic)
// ════════════════════════════════════════════════════════
const modal = $("#log-modal");
const form = $("#log-form");
const typeEl = $("#log-type");
const valEl = $("#log-value");
const dateEl = $("#log-date");
const notesEl = $("#log-notes");
const ratingEl = $("#log-rating-value");
let selectedRating = 0;
$$("#modal-pips .modal-pip").forEach(btn => {
btn.addEventListener("click", () => {
selectedRating = parseInt(btn.dataset.val);
ratingEl.value = selectedRating;
$$("#modal-pips .modal-pip").forEach(b => {
b.classList.toggle("selected", parseInt(b.dataset.val) <= selectedRating);
});
});
});
function openModal(preType = null) {
form.reset();
selectedRating = 0;
ratingEl.value = "";
$$("#modal-pips .modal-pip").forEach(b => b.classList.remove("selected"));
dateEl.value = isoDate();
if (preType) typeEl.value = preType;
updateModalFields();
modal.showModal();
}
function openMeetingModal() {
form.reset();
dateEl.value = isoDate();
typeEl.value = "shaykh_meeting";
updateModalFields();
modal.showModal();
}
function updateModalFields() {
const type = typeEl.value;
const isRating = type === "wuqoof_qalbi";
const isMeeting = type === "shaykh_meeting";
$("#value-group").style.display = (!isRating && !isMeeting) ? "" : "none";
$("#rating-group").style.display = isRating ? "" : "none";
$("#shaykh-group").style.display = isMeeting ? "" : "none";
if (!isRating && !isMeeting) {
const meta = DAILY_WIRD[type];
if (meta) valEl.placeholder = `Target: ${meta.target} ${meta.unit}`;
}
}
if (typeEl){
typeEl.addEventListener("change", updateModalFields);
}
$("#modal-cancel").addEventListener("click", () => modal.close());
if (form){
form.addEventListener("submit", async (e) => {
e.preventDefault();
const type = typeEl.value;
const isRating = type === "wuqoof_qalbi";
const isMtg = type === "shaykh_meeting";
if (isRating && !ratingEl.value) { alert("Please select a rating."); return; }
const payload = {
wirdType: type,
date: dateEl.value,
value: isRating ? Number(ratingEl.value) : isMtg ? 1 : Number(valEl.value),
notes: notesEl.value.trim() || null,
};
try {
const saved = await postEntry(payload);
allEntries.push(saved);
buildTodayMap();
buildMeetingLog();
renderToday();
renderNafl();
renderMeetingPanel();
renderHistory();
renderChart();
renderHeatmap();
modal.close();
} catch (err) {
alert("Could not save entry: " + err.message);
}
});
}
// ════════════════════════════════════════════════════════
// NAFL PRAYERS
// Stored in wird_entries: wirdType = "salatul_tawbah" |
// "salatul_hajaat" | "tahajjud", value = 1 when prayed.
// Toggle = delete existing today entry, or post new one.
// ════════════════════════════════════════════════════════
function renderNafl() {
const today = isoDate();
// Build a map of which nafl are done today and their entry id
const naflToday = {}; // { wirdType: { id, done: true } }
for (const e of allEntries) {
if (e.date !== today) continue;
if (!NAFL_WIRD[e.wirdType]) continue;
if (Number(e.value) >= 1) naflToday[e.wirdType] = e.id;
}
const done = Object.keys(naflToday).length;
const total = Object.keys(NAFL_WIRD).length;
// Render each card
for (const [type, meta] of Object.entries(NAFL_WIRD)) {
const card = document.getElementById(`nafl-${type}`);
if (!card) continue;
const isDone = !!naflToday[type];
card.classList.toggle("nafl-done", isDone);
const btn = card.querySelector(".nafl-toggle");
btn.textContent = isDone ? "✓ prayed" : "Mark as prayed";
btn.dataset.type = type;
btn.dataset.entryId = naflToday[type] || "";
}
// Progress bar — track width = 120px for 3/3
const barPx = Math.round((done / total) * 120);
$("#nafl-completion-bar").style.width = barPx + "px";
$("#nafl-completion-label").textContent = `${done} / ${total} today`;
}
// Single delegated listener on the nafl-cards container
$("#nafl-cards").addEventListener("click", async e => {
const btn = e.target.closest(".nafl-toggle");
if (!btn) return;
const type = btn.dataset.type;
const entryId = btn.dataset.entryId;
const today = isoDate();
btn.disabled = true;
try {
if (entryId) {
// Toggle OFF — delete
await deleteEntry(entryId);
allEntries = allEntries.filter(en => String(en.id) !== String(entryId));
} else {
// Toggle ON — post
const saved = await postEntry({ wirdType: type, date: today, value: 1, notes: null });
allEntries.push(saved);
}
buildTodayMap();
renderNafl();
renderHeatmap();
} catch (err) {
alert("Could not update: " + err.message);
} finally {
btn.disabled = false;
}
});
// ════════════════════════════════════════════════════════
// MEETING PANEL (unchanged)
// ════════════════════════════════════════════════════════
function renderMeetingPanel() {
const today = isoDate();
const last = meetingLog[0] || null;
const lastDate = last ? last.date : null;
const nextDate = lastDate ? addDays(lastDate, MEETING_CYCLE_DAYS) : null;
const daysToNext = nextDate ? daysBetween(today, nextDate) : null;
let statusHtml = "";
if (!lastDate) {
statusHtml = `No meetings logged yet `;
} else if (daysToNext > 0) {
statusHtml = `Next due in ${daysToNext} day${daysToNext !== 1 ? "s" : ""} — ${fmtDisplay(nextDate)} `;
} else if (daysToNext === 0) {
statusHtml = `Due today `;
} else {
const ov = Math.abs(daysToNext);
statusHtml = `${ov} day${ov !== 1 ? "s" : ""} overdue — expected ${fmtDisplay(nextDate)} `;
}
const cycles = buildCycleInsights();
const cycleRows = cycles.map(c => `
${fmtDisplay(c.start)} → ${fmtDisplay(c.end)}
${c.met
? `✓ met — ${fmtDisplay(c.metOn)} `
: c.ongoing
? `ongoing `
: `✗ missed `}
${c.met ? `day ${c.dayOfCycle}` : "—"}
`).join("");
$("#meeting-panel-body").innerHTML = `
Last meeting
${lastDate ? fmtDisplay(lastDate) : "—"}
Next expected
${nextDate ? fmtDisplay(nextDate) : "—"}
${statusHtml}
3-week cycles
${cycles.length === 0
? `
Log at least one meeting to see cycle insights.
`
: `
Cycle window Status When
${cycleRows}
`}
`;
}
function buildCycleInsights() {
if (meetingLog.length === 0) return [];
const dates = [...meetingLog].reverse().map(e => e.date);
const today = isoDate();
const cycles = [];
let start = dates[0];
while (start <= today) {
const end = addDays(start, MEETING_CYCLE_DAYS - 1);
const hit = dates.find(d => d >= start && d <= end);
cycles.push({
start, end,
met: !!hit,
metOn: hit || null,
dayOfCycle: hit ? daysBetween(start, hit) + 1 : null,
ongoing: end > today,
});
start = addDays(end, 1);
}
return cycles.reverse();
}
// ════════════════════════════════════════════════════════
// KHATM TRACKER
// Stored in wird_entries: wirdType="khatm", value=1.
// notes field carries JSON: {"type":"tilawah"} or plain text.
// ════════════════════════════════════════════════════════
function getKhatmEntries() {
return [...allEntries]
.filter(e => e.wirdType === "khatm" && Number(e.value) >= 1)
.sort((a, b) => a.date.localeCompare(b.date)); // asc for numbering
}
function renderKhatm() {
const entries = getKhatmEntries();
const total = entries.length;
const thisYear = entries.filter(
e => e.date.startsWith(String(new Date().getFullYear()))
).length;
let avgDays = "—";
if (entries.length >= 2) {
let gap = 0;
for (let i = 1; i < entries.length; i++)
gap += daysBetween(entries[i - 1].date, entries[i].date);
avgDays = Math.round(gap / (entries.length - 1));
}
const last = entries[entries.length - 1];
$("#khatm-total").textContent = total || "0";
$("#khatm-this-year").textContent = thisYear || "0";
$("#khatm-avg-days").textContent = avgDays;
$("#khatm-last").textContent = last ? fmtDisplay(last.date) : "—";
const listEl = $("#khatm-list");
if (entries.length === 0) {
listEl.innerHTML = `No completions logged yet.
`;
return;
}
// Render newest first
const display = [...entries].reverse();
listEl.innerHTML = display.map(e => {
const num = entries.findIndex(x => x.id === e.id) + 1;
const milestone = num % 10 === 0;
const prevEntry = entries[num - 2]; // ascending, so num-2 is previous
const gapTxt = prevEntry ? `+${daysBetween(prevEntry.date, e.date)}d` : "first";
// Parse recitation type out of notes if stored as JSON
let recType = "", noteText = e.notes || "";
try {
const parsed = JSON.parse(e.notes);
recType = parsed.type || "";
noteText = parsed.notes || "";
} catch (_) { /* notes is plain text */ }
const typeBadge = recType
? `${KHATM_TYPES[recType] || recType} `
: "";
const note = noteText
? `${noteText} `
: "";
return `
${num}
${fmtDisplay(e.date)}
${typeBadge}
${gapTxt}
${note}
`;
}).join("");
}
// Khatm modal
const khatmModal = $("#khatm-modal");
const khatmForm = $("#khatm-form");
const khatmDateEl = $("#khatm-date");
const khatmTypeEl = $("#khatm-recitation");
const khatmNotesEl = $("#khatm-notes");
$("#log-khatm-btn").addEventListener("click", () => {
khatmForm.reset();
khatmDateEl.value = isoDate();
khatmModal.showModal();
});
$("#khatm-cancel").addEventListener("click", () => khatmModal.close());
khatmForm.addEventListener("submit", async e => {
e.preventDefault();
// Pack recitation type + notes into the notes field as JSON
const recType = khatmTypeEl.value || "";
const noteText = khatmNotesEl.value.trim();
const notes = (recType || noteText)
? JSON.stringify({ type: recType || undefined, notes: noteText || undefined })
: null;
try {
const saved = await postEntry({
wirdType: "khatm",
date: khatmDateEl.value,
value: 1,
notes,
});
allEntries.push(saved);
khatmModal.close();
renderKhatm();
renderHeatmap();
} catch (err) {
alert("Error saving khatm: " + err.message);
}
});
// ════════════════════════════════════════════════════════
// HISTORY TABLE (filter out nafl + khatm to keep it clean,
// or you can remove the filter if you want them shown)
// ════════════════════════════════════════════════════════
function renderHistory() {
const tbody = $("#history-body");
tbody.innerHTML = "";
const HIDE_TYPES = new Set(["shaykh_meeting", ...Object.keys(NAFL_WIRD), "khatm"]);
const sorted = [...allEntries]
.filter(e => !HIDE_TYPES.has(e.wirdType))
.sort((a, b) => b.date.localeCompare(a.date))
.slice(0, 20);
if (sorted.length === 0) {
tbody.innerHTML = `No entries yet. `;
return;
}
for (const e of sorted) {
const meta = WIRD_META[e.wirdType] || {};
const cls = statusClass(e.wirdType, e.value);
const badgeTxt = cls === "over" ? (meta.type === "rating" ? "flourishing" : "above")
: cls === "done" ? (meta.type === "rating" ? "present" : "met")
: cls === "miss" ? "below"
: "—";
const tr = document.createElement("tr");
tr.innerHTML = `
${e.date}
${meta.label || e.wirdType}
${fmtValue(e.wirdType, e.value)}
${badgeTxt}
${e.notes || ""}
`;
tbody.appendChild(tr);
}
}
// ════════════════════════════════════════════════════════
// TREND CHART (unchanged)
// ════════════════════════════════════════════════════════
function renderChart() {
const type = $("#trend-type").value;
const days = parseInt($("#trend-range").value);
const meta = WIRD_META[type];
const isRating = meta.type === "rating";
const now = new Date();
const labels = [];
const vals = [];
const isMotalah = type === "motalah";
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const iso = isoDate(d);
labels.push(iso.slice(5));
if (isMotalah) {
vals.push(motalahEntries.filter(e => e.date === iso)
.reduce((s, e) => s + Number(e.durationMinutes), 0));
} else {
const dayEntries = allEntries.filter(e => e.date === iso && e.wirdType === type);
if (isRating) {
const last = dayEntries.sort((a, b) => a.createdAt > b.createdAt ? 1 : -1).pop();
vals.push(last ? Number(last.value) : 0);
} else {
vals.push(dayEntries.reduce((s, e) => s + Number(e.value), 0));
}
}
}
const nonZero = vals.filter(v => v > 0);
const avg = nonZero.length
? (isRating
? (nonZero.reduce((a, b) => a + b, 0) / nonZero.length).toFixed(1)
: Math.round(nonZero.reduce((a, b) => a + b, 0) / nonZero.length))
: 0;
const max = nonZero.length ? Math.max(...nonZero) : 0;
const streak = calcStreak(type);
if (isRating) {
$("#trend-stats").innerHTML = `
${avg} avg rating
${RATING_LABELS[max] || "—"} best day
${streak}d streak ≥ ${meta.target}
${nonZero.length} days logged
`;
} else {
$("#trend-stats").innerHTML = `
${Number(avg).toLocaleString()} avg / day
${Number(max).toLocaleString()} best day
${streak}d current streak
${meta.target.toLocaleString()} daily target
`;
}
const cs = getComputedStyle(document.documentElement);
const accent = cs.getPropertyValue("--wird-accent").trim() || "#7c5c3a";
const gridColor = cs.getPropertyValue("--border").trim() || "#d7d7d7";
const tickColor = cs.getPropertyValue("--muted").trim() || "#666";
const ctx = $("#trend-chart").getContext("2d");
if (chart) chart.destroy();
chart = new Chart(ctx, {
type: "line",
data: {
labels,
datasets: [
{
label: meta.label,
data: vals,
borderColor: accent,
backgroundColor: accent + "22",
fill: true, tension: .35, pointRadius: 3, pointHoverRadius: 5,
},
{
label: "Target",
data: vals.map(() => meta.target),
borderColor: "rgba(74,124,89,.5)",
borderDash: [5, 4],
pointRadius: 0, fill: false, tension: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => {
const v = ctx.parsed.y;
if (isRating && ctx.datasetIndex === 0)
return `${meta.label}: ${RATING_LABELS[v] || v} (${v}/5)`;
return `${ctx.dataset.label}: ${v}`;
},
},
},
},
scales: {
x: {
grid: { color: gridColor + "22" },
ticks: { color: tickColor, font: { family: "monospace", size: 10 }, maxTicksLimit: 10 },
},
y: {
grid: { color: gridColor + "22" },
ticks: {
color: tickColor,
font: { family: "monospace", size: 10 },
...(isRating ? {
min: 0, max: 5, stepSize: 1,
callback: v => RATING_LABELS[v] || (v === 0 ? "" : v),
} : {}),
},
beginAtZero: true,
...(isRating ? { min: 0, max: 5 } : {}),
},
},
},
});
}
function calcStreak(type) {
const meta = WIRD_META[type];
let streak = 0;
const now = new Date();
for (let i = 0; i < 365; i++) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const iso = isoDate(d);
let total;
if (type === "motalah") {
total = motalahEntries.filter(e => e.date === iso)
.reduce((s, e) => s + Number(e.durationMinutes), 0);
} else {
const entries = allEntries.filter(e => e.date === iso && e.wirdType === type);
if (meta && meta.type === "rating") {
const last = entries.sort((a, b) => a.createdAt > b.createdAt ? 1 : -1).pop();
total = last ? Number(last.value) : 0;
} else {
total = entries.reduce((s, e) => s + Number(e.value), 0);
}
}
const target = type === "motalah" ? MOTALAH_TARGET : (meta ? meta.target : 1);
if (total >= target) streak++;
else if (i > 0) break;
}
return streak;
}
// ════════════════════════════════════════════════════════
// HEATMAP
// Reads allEntries directly. No extra API call.
// ════════════════════════════════════════════════════════
function renderHeatmap() {
const type = $("#heatmap-type").value;
const today = new Date();
const todayIso = isoDate(today);
// Anchor: Sunday on or before today, then go back (HEATMAP_WEEKS-1) full weeks
const anchor = new Date(today);
anchor.setDate(anchor.getDate() - anchor.getDay());
const startDate = new Date(anchor);
startDate.setDate(startDate.getDate() - (HEATMAP_WEEKS - 1) * 7);
const scoreMap = buildHeatmapScores(type, isoDate(startDate), todayIso);
// ── Month labels ──────────────────────────────────────
const monthLabelEl = $("#heatmap-month-labels");
monthLabelEl.innerHTML = "";
let lastMonth = null;
// 15px per col (12px cell + 3px gap); labels are position:absolute
for (let w = 0; w < HEATMAP_WEEKS; w++) {
const d = new Date(startDate);
d.setDate(d.getDate() + w * 7);
const mo = d.toLocaleDateString("en-GB", { month: "short" });
if (mo !== lastMonth) {
const lbl = document.createElement("span");
lbl.className = "hm-month-lbl";
lbl.style.left = w * 15 + "px";
lbl.textContent = mo;
monthLabelEl.appendChild(lbl);
lastMonth = mo;
}
}
// ── Grid ─────────────────────────────────────────────
const gridEl = $("#heatmap-grid");
gridEl.innerHTML = "";
// Day-of-week label column
const dowCol = document.createElement("div");
dowCol.className = "hm-dow-col";
["", "Mon", "", "Wed", "", "Fri", ""].forEach(lbl => {
const el = document.createElement("div");
el.className = "hm-dow-lbl";
el.textContent = lbl;
dowCol.appendChild(el);
});
gridEl.appendChild(dowCol);
for (let w = 0; w < HEATMAP_WEEKS; w++) {
const col = document.createElement("div");
col.className = "hm-col";
for (let d = 0; d < 7; d++) {
const cellDate = new Date(startDate);
cellDate.setDate(cellDate.getDate() + w * 7 + d);
const iso = isoDate(cellDate);
const cell = document.createElement("div");
cell.className = "hm-cell";
if (iso > todayIso) {
cell.classList.add("hm-empty");
} else {
const score = scoreMap[iso] || 0;
cell.dataset.level = scoreToLevel(score, type);
if (iso === todayIso) cell.classList.add("hm-today");
cell.title = buildTooltip(iso, score, type);
}
col.appendChild(cell);
}
gridEl.appendChild(col);
}
// ── Stats ─────────────────────────────────────────────
renderHeatmapStats(scoreMap, type, isoDate(startDate), todayIso);
}
function buildHeatmapScores(type, startIso, endIso) {
// Returns { "2025-01-14": score } where score is a 0–1+ fraction
// (against the relevant target) so scoreToLevel() is consistent.
const map = {};
if (type === "__all__") {
// Fraction of DAILY_WIRD targets met each day
const dayCounts = {}; // { date: { type: total } }
for (const e of allEntries) {
if (e.date < startIso || e.date > endIso) continue;
if (!DAILY_WIRD[e.wirdType]) continue;
if (!dayCounts[e.date]) dayCounts[e.date] = {};
const meta = DAILY_WIRD[e.wirdType];
if (meta.type === "rating") {
dayCounts[e.date][e.wirdType] = Math.max(
dayCounts[e.date][e.wirdType] || 0, Number(e.value)
);
} else {
dayCounts[e.date][e.wirdType] = (dayCounts[e.date][e.wirdType] || 0) + Number(e.value);
}
}
const total = Object.keys(DAILY_WIRD).length;
for (const [date, typeMap] of Object.entries(dayCounts)) {
let met = 0;
for (const [t, val] of Object.entries(typeMap)) {
if (val >= DAILY_WIRD[t].target) met++;
}
map[date] = met / total;
}
return map;
}
// Nafl: fraction of 3 prayers done
if (type === "nafl") {
for (const e of allEntries) {
if (!NAFL_WIRD[e.wirdType]) continue;
if (e.date < startIso || e.date > endIso) continue;
if (Number(e.value) >= 1) map[e.date] = (map[e.date] || 0) + (1 / 3);
}
return map;
}
// Khatm: binary
if (type === "khatm") {
for (const e of allEntries) {
if (e.wirdType !== "khatm") continue;
if (e.date < startIso || e.date > endIso) continue;
if (Number(e.value) >= 1) map[e.date] = 1;
}
return map;
}
// Motalah: use motalahEntries, normalised against MOTALAH_TARGET
if (type === "motalah") {
for (const e of motalahEntries) {
if (e.date < startIso || e.date > endIso) continue;
map[e.date] = (map[e.date] || 0) + Number(e.durationMinutes);
}
for (const d of Object.keys(map)) map[d] = map[d] / MOTALAH_TARGET;
return map;
}
// Single wird type — normalised against target
const meta = WIRD_META[type];
for (const e of allEntries) {
if (e.wirdType !== type) continue;
if (e.date < startIso || e.date > endIso) continue;
if (meta && meta.type === "rating") {
map[e.date] = Math.max(map[e.date] || 0, Number(e.value));
} else {
map[e.date] = (map[e.date] || 0) + Number(e.value);
}
}
if (meta && meta.target) {
for (const d of Object.keys(map)) map[d] = map[d] / meta.target;
}
return map;
}
function scoreToLevel(score, type) {
if (type === "khatm") return score > 0 ? 4 : 0;
if (score <= 0) return 0;
if (score < 0.4) return 1;
if (score < 0.75) return 2;
if (score < 1.0) return 3;
return 4; // met or exceeded
}
function buildTooltip(iso, score, type) {
const label = fmtDisplay(iso);
if (score <= 0) return `${label}: nothing logged`;
if (type === "__all__") return `${label}: ${Math.round(score * 100)}% of targets met`;
if (type === "nafl") return `${label}: ${Math.round(score * 3)} / 3 prayers`;
if (type === "khatm") return `${label}: khatm completed ✓`;
if (type === "motalah") return `${label}: ${Math.round(score * MOTALAH_TARGET)} min`;
const meta = WIRD_META[type];
if (!meta) return label;
if (meta.type === "rating") {
const r = Math.round(score);
return `${label}: ${RATING_LABELS[r] || r}/5`;
}
const raw = score * (meta.target || 1);
return `${label}: ${Math.round(raw).toLocaleString()} ${meta.unit}`;
}
// ============================================================
// DAILY SUMMARY TABLE
// ============================================================
let summaryDays = 30;
let summaryFilter = "__all__";
function renderSummary() {
const wrap = document.getElementById("summary-table-wrap");
if (!wrap) return;
const today = isoDate();
const cols = summaryFilter === "__all__"
? Object.keys(DAILY_WIRD)
: [summaryFilter];
// Build date rows newest→oldest
const rows = [];
for (let i = 0; i < summaryDays; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
rows.push(isoDate(d));
}
// Pre-aggregate entries into { date: { type: val } }
const agg = {};
for (const e of allEntries) {
if (!cols.includes(e.wirdType)) continue;
if (!agg[e.date]) agg[e.date] = {};
const meta = DAILY_WIRD[e.wirdType];
if (meta && meta.type === "rating") {
agg[e.date][e.wirdType] = Math.max(
agg[e.date][e.wirdType] || 0, Number(e.value)
);
} else {
agg[e.date][e.wirdType] = (agg[e.date][e.wirdType] || 0) + Number(e.value);
}
}
// Build header
const thCols = cols.map(t => {
const meta = DAILY_WIRD[t];
const unitHint = meta.type === "rating" ? "/5" : meta.unit ? meta.unit : "";
return `
${meta.label}
/ ${meta.target}${unitHint}
`;
}).join("");
// Build body rows
const bodyRows = rows.map(dateIso => {
const isToday = dateIso === today;
const dayData = agg[dateIso] || {};
const displayDate = new Date(dateIso + "T00:00:00").toLocaleDateString("en-GB", {
weekday: "short", day: "numeric", month: "short"
});
const cells = cols.map(type => {
const meta = DAILY_WIRD[type];
const val = dayData[type];
if (val === undefined || val === null) {
return `— `;
}
const cls = statusClass(type, val);
const cellCls = cls === "over" ? "summary-cell-over"
: cls === "done" ? "summary-cell-done"
: cls === "miss" ? "summary-cell-miss"
: "";
let display;
if (meta.type === "rating") {
display = `${val}/5 `;
} else {
display = Number(val).toLocaleString();
}
return `${display} `;
}).join("");
return `
${displayDate}${isToday ? " ·" : ""}
${cells}
`;
}).join("");
// Footer: days target met per column
const footCells = cols.map(type => {
const meta = DAILY_WIRD[type];
let metCount = 0;
for (const dateIso of rows) {
const val = (agg[dateIso] || {})[type];
if (val !== undefined && val >= meta.target) metCount++;
}
const pct = Math.round((metCount / summaryDays) * 100);
const cls = pct >= 80 ? "summary-cell-done" : pct >= 50 ? "" : "summary-cell-miss";
return ``;
}).join("");
wrap.innerHTML = `
Date
${thCols}
${bodyRows}
${footCells}
`;
}
// Controls
document.querySelectorAll(".summary-preset").forEach(btn => {
btn.addEventListener("click", () => {
summaryDays = parseInt(btn.dataset.days);
document.querySelectorAll(".summary-preset").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
renderSummary();
});
});
const summaryFilterEl = document.getElementById("summary-filter");
if (summaryFilterEl) {
summaryFilterEl.addEventListener("change", () => {
summaryFilter = summaryFilterEl.value;
renderSummary();
});
}
function renderHeatmapStats(scoreMap, type, startIso, endIso) {
const el = $("#heatmap-stats");
const daysLogged = Object.values(scoreMap).filter(v => v > 0).length;
const daysTarget = Object.values(scoreMap).filter(v => v >= 1).length;
// Longest streak of any-activity
let longest = 0, cur = 0;
const s = new Date(startIso), e = new Date(endIso);
for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
if ((scoreMap[isoDate(d)] || 0) > 0) { cur++; longest = Math.max(longest, cur); }
else cur = 0;
}
const chips = [
{ val: daysLogged, lbl: "days with activity" },
{ val: `${longest}d`, lbl: "longest streak" },
];
if (type !== "khatm") {
chips.push({ val: daysTarget, lbl: type === "nafl" ? "days all prayers done" : "days target met" });
}
el.innerHTML = chips.map(c =>
`
${c.val}
${c.lbl}
`
).join("");
}
// ════════════════════════════════════════════════════════
// BIND CONTROLS
// ════════════════════════════════════════════════════════
function bindControls() {
$("#open-log-btn").addEventListener("click", () => openModal());
$("#log-meeting-btn").addEventListener("click", openMeetingModal);
$("#trend-type").addEventListener("change", renderChart);
$("#trend-range").addEventListener("change", renderChart);
$("#heatmap-type").addEventListener("change", renderHeatmap);
}
/* ============================================================
wird-export.js
Client-side PDF export for the Wird Tracker.
Depends on: jsPDF + jspdf-autotable (loaded via CDN).
All data is read from allEntries / motalahEntries already
held in memory by wird-tracker.js — no extra API calls.
HOW TO INTEGRATE
─────────────────
1. Paste this block INSIDE the existing IIFE in
wird-tracker.js, just before the closing })();
2. Add the HTML snippet (wird-export-html.html) to the
template and the CDN