Files
org_web/assets/scripts/pages/wird-tracker.js
gitea-actions 7afc554f5d
All checks were successful
Build Org Website / build (push) Successful in 44s
Refactor org web content and simplify shared UI
2026-07-29 15:17:17 +01:00

1790 lines
62 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
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 = `<p style="color:var(--muted);font-style:italic">No study sessions logged yet.</p>`;
return;
}
wrap.innerHTML = sorted.map(e => {
const bookNames = (e.bookIds || [])
.map(id => {
const b = calibreBooks.find(b => String(b.id) === String(id));
return b ? `<span class="motalah-book-chip">${b.title}</span>` : "";
}).join("");
const note = e.notes
? `<span class="motalah-session-note"> — ${e.notes}</span>`
: "";
return `
<div class="motalah-session">
<span class="motalah-session-date">${e.date}</span>
<span class="motalah-session-dur">${e.durationMinutes} min</span>
<span class="motalah-session-books">${bookNames || ""}${note}</span>
</div>
`;
}).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 `<span class="selected-book-chip" data-id="${id}">
${label}
<button type="button" data-id="${id}" title="Remove">×</button>
</span>`;
}).join("");
// Sync hidden select so the existing submit handler still works
motalahBooksEl.innerHTML = [...selectedBookIds]
.map(id => `<option value="${id}" selected></option>`).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 = `<div class="book-no-results">No books found</div>`;
} else {
bookSearchResults.innerHTML = filtered.slice(0, 40).map(b => {
const sel = selectedBookIds.has(String(b.id));
return `<div class="book-result-item${sel ? " selected" : ""}" data-id="${b.id}">
<div class="book-result-check">${sel ? "✓" : ""}</div>
<span class="book-result-title">${b.title}</span>
${b.authors ? `<span class="book-result-author">${b.authors}</span>` : ""}
</div>`;
}).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 = `
<div class="card-name">${meta.label}</div>
<div class="card-meta">
${fmtValue(type, val)}
<span style="opacity:.5"> / ${meta.target.toLocaleString()} ${meta.unit}</span>
</div>
<div class="card-bar-wrap">
<div class="card-bar-fill" style="width:${pct}%"></div>
</div>
<button class="card-log-btn">+ log</button>
`;
}
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 `<span class="card-pip ${filled ? "filled" : ""} ${isMin ? "min-marker" : ""}"
title="${RATING_LABELS[n]}"></span>`;
}).join("");
const lbl = val ? `${RATING_LABELS[val]} <span style="opacity:.45">(${val}/5)</span>` : "not logged";
return `
<div class="card-name">${meta.label}</div>
<div class="card-pips">${pips}</div>
<div class="card-meta card-rating-lbl">${lbl}</div>
<button class="card-log-btn">+ log</button>
`;
}
// ════════════════════════════════════════════════════════
// 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 = `<span class="meeting-status pending">No meetings logged yet</span>`;
} else if (daysToNext > 0) {
statusHtml = `<span class="meeting-status ok">Next due in <strong>${daysToNext}</strong> day${daysToNext !== 1 ? "s" : ""} &mdash; ${fmtDisplay(nextDate)}</span>`;
} else if (daysToNext === 0) {
statusHtml = `<span class="meeting-status due">Due today</span>`;
} else {
const ov = Math.abs(daysToNext);
statusHtml = `<span class="meeting-status overdue"><strong>${ov}</strong> day${ov !== 1 ? "s" : ""} overdue &mdash; expected ${fmtDisplay(nextDate)}</span>`;
}
const cycles = buildCycleInsights();
const cycleRows = cycles.map(c => `
<tr>
<td>${fmtDisplay(c.start)} &rarr; ${fmtDisplay(c.end)}</td>
<td>${c.met
? `<span class="badge done">✓ met — ${fmtDisplay(c.metOn)}</span>`
: c.ongoing
? `<span class="badge">ongoing</span>`
: `<span class="badge miss">✗ missed</span>`}
</td>
<td style="font-family:monospace;font-size:.7rem;color:var(--muted)">${c.met ? `day ${c.dayOfCycle}` : "—"}</td>
</tr>
`).join("");
$("#meeting-panel-body").innerHTML = `
<div id="meeting-status-row">
<div>
<span class="meeting-meta-lbl">Last meeting</span>
<span class="meeting-meta-val">${lastDate ? fmtDisplay(lastDate) : "—"}</span>
</div>
<div>
<span class="meeting-meta-lbl">Next expected</span>
<span class="meeting-meta-val">${nextDate ? fmtDisplay(nextDate) : "—"}</span>
</div>
<div id="meeting-status-msg">${statusHtml}</div>
</div>
<div id="meeting-cycles">
<h3>3-week cycles</h3>
${cycles.length === 0
? `<p style="color:var(--muted);font-size:.88rem">Log at least one meeting to see cycle insights.</p>`
: `<table class="meeting-cycle-table">
<thead><tr><th>Cycle window</th><th>Status</th><th>When</th></tr></thead>
<tbody>${cycleRows}</tbody>
</table>`}
</div>
`;
}
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 = `<p style="color:var(--muted);font-style:italic">No completions logged yet.</p>`;
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
? `<span class="khatm-type-badge">${KHATM_TYPES[recType] || recType}</span>`
: "";
const note = noteText
? `<span class="khatm-note">${noteText}</span>`
: "";
return `
<div class="khatm-item${milestone ? " milestone" : ""}">
<div class="khatm-num" title="${milestone ? "🎉 Milestone!" : `#${num}`}">${num}</div>
<span class="khatm-date">${fmtDisplay(e.date)}</span>
${typeBadge}
<span class="khatm-gap">${gapTxt}</span>
${note}
</div>
`;
}).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 = `<tr><td colspan="5" class="loading-cell">No entries yet.</td></tr>`;
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 = `
<td style="font-family:monospace;font-size:.75rem">${e.date}</td>
<td>${meta.label || e.wirdType}</td>
<td style="font-family:monospace;font-size:.82rem">${fmtValue(e.wirdType, e.value)}</td>
<td><span class="badge ${cls}">${badgeTxt}</span></td>
<td style="color:var(--muted);font-size:.82rem">${e.notes || ""}</td>
`;
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 = `
<div class="stat-chip"><span class="stat-val">${avg}</span><span class="stat-lbl">avg rating</span></div>
<div class="stat-chip"><span class="stat-val">${RATING_LABELS[max] || "—"}</span><span class="stat-lbl">best day</span></div>
<div class="stat-chip"><span class="stat-val">${streak}d</span><span class="stat-lbl">streak ≥ ${meta.target}</span></div>
<div class="stat-chip"><span class="stat-val">${nonZero.length}</span><span class="stat-lbl">days logged</span></div>
`;
} else {
$("#trend-stats").innerHTML = `
<div class="stat-chip"><span class="stat-val">${Number(avg).toLocaleString()}</span><span class="stat-lbl">avg / day</span></div>
<div class="stat-chip"><span class="stat-val">${Number(max).toLocaleString()}</span><span class="stat-lbl">best day</span></div>
<div class="stat-chip"><span class="stat-val">${streak}d</span><span class="stat-lbl">current streak</span></div>
<div class="stat-chip"><span class="stat-val">${meta.target.toLocaleString()}</span><span class="stat-lbl">daily target</span></div>
`;
}
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 01+ 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 `<th title="${meta.label} · target: ${meta.target}${unitHint}">
${meta.label}<br>
<span style="font-weight:400;opacity:.6">/ ${meta.target}${unitHint}</span>
</th>`;
}).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 `<td class="summary-cell-empty">—</td>`;
}
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}<span style="opacity:.45">/5</span>`;
} else {
display = Number(val).toLocaleString();
}
return `<td class="${cellCls}">${display}</td>`;
}).join("");
return `<tr class="${isToday ? "summary-today" : ""}">
<td>${displayDate}${isToday ? " ·" : ""}</td>
${cells}
</tr>`;
}).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 `<td class="summary-tfoot ${cls}">${metCount}d <span style="opacity:.55">(${pct}%)</span></td>`;
}).join("");
wrap.innerHTML = `
<table id="summary-table">
<thead>
<tr>
<th>Date</th>
${thCols}
</tr>
</thead>
<tbody>${bodyRows}</tbody>
<tfoot>
<tr>
<td class="summary-tfoot" style="color:var(--muted)">days met</td>
${footCells}
</tr>
</tfoot>
</table>
`;
}
// 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 =>
`<div class="stat-chip">
<span class="stat-val">${c.val}</span>
<span class="stat-lbl">${c.lbl}</span>
</div>`
).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 <script> tags after chart.js.
============================================================ */
// ── Export modal elements ─────────────────────────────────
const exportModal = document.getElementById("export-modal");
const exportForm = document.getElementById("export-form");
const exportFromEl = document.getElementById("export-from");
const exportToEl = document.getElementById("export-to");
const exportBtnLabel = document.getElementById("export-btn-label");
const exportSubmitBtn = document.getElementById("export-submit");
// ── Open / close ──────────────────────────────────────────
document.getElementById("export-pdf-btn").addEventListener("click", () => {
const today = isoDate();
const thirtyDaysAgo = (() => {
const d = new Date(); d.setDate(d.getDate() - 30); return isoDate(d);
})();
exportFromEl.value = thirtyDaysAgo;
exportToEl.value = today;
exportModal.showModal();
});
document.getElementById("export-cancel").addEventListener("click", () => exportModal.close());
// ── Preset buttons ────────────────────────────────────────
document.querySelectorAll(".export-preset").forEach(btn => {
btn.addEventListener("click", () => {
const days = parseInt(btn.dataset.preset);
const to = new Date();
const from = new Date();
if (days === 365) {
// "This year" = Jan 1 of current year
from.setMonth(0, 1);
} else {
from.setDate(from.getDate() - (days - 1));
}
exportFromEl.value = isoDate(from);
exportToEl.value = isoDate(to);
// Visual feedback on active preset
document.querySelectorAll(".export-preset").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
});
});
// Clear active preset when user manually changes dates
[exportFromEl, exportToEl].forEach(el => {
el.addEventListener("change", () => {
document.querySelectorAll(".export-preset").forEach(b => b.classList.remove("active"));
});
});
// ── Generate PDF ──────────────────────────────────────────
exportForm.addEventListener("submit", async e => {
e.preventDefault();
const fromIso = exportFromEl.value;
const toIso = exportToEl.value;
if (!fromIso || !toIso || fromIso > toIso) {
alert("Please select a valid date range.");
return;
}
const includeNafl = document.getElementById("export-include-nafl").checked;
const includeKhatm = document.getElementById("export-include-khatm").checked;
const includeMotalah = document.getElementById("export-include-motalah").checked;
// Loading state
exportSubmitBtn.disabled = true;
exportBtnLabel.textContent = "Generating…";
try {
await generatePDF({ fromIso, toIso, includeNafl, includeKhatm, includeMotalah });
exportModal.close();
} catch (err) {
console.error("PDF export error:", err);
alert("Could not generate PDF: " + err.message);
} finally {
exportSubmitBtn.disabled = false;
exportBtnLabel.textContent = "Download PDF";
}
});
// ── Core PDF generation ───────────────────────────────────
async function generatePDF({ fromIso, toIso, includeNafl, includeKhatm, includeMotalah }) {
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const PAGE_W = 210;
const MARGIN = 16;
const COL_W = PAGE_W - MARGIN * 2;
const ACCENT = [124, 92, 58]; // --wird-accent (light mode safe)
const MUTED = [130, 130, 130];
const FG = [30, 30, 30];
const BORDER = [220, 215, 208];
// ── Cover / header ─────────────────────────────────────
doc.setFillColor(...ACCENT);
doc.rect(0, 0, PAGE_W, 28, "F");
doc.setFont("helvetica", "bold");
doc.setFontSize(16);
doc.setTextColor(255, 255, 255);
doc.text("Wird Tracker — Export", MARGIN, 16);
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
doc.setTextColor(255, 255, 255, 0.75);
doc.text(
`${fmtDisplay(fromIso)}${fmtDisplay(toIso)} · generated ${fmtDisplay(isoDate())}`,
MARGIN, 23
);
let cursorY = 38;
// ── Helper: section heading ────────────────────────────
function sectionHeading(title, y) {
doc.setFillColor(...BORDER);
doc.rect(MARGIN, y, COL_W, 7, "F");
doc.setFont("helvetica", "bold");
doc.setFontSize(8.5);
doc.setTextColor(...ACCENT);
doc.text(title.toUpperCase(), MARGIN + 3, y + 5);
return y + 10;
}
// ── Helper: summary stat row ───────────────────────────
function statRow(label, value, y) {
doc.setFont("helvetica", "normal");
doc.setFontSize(8.5);
doc.setTextColor(...MUTED);
doc.text(label, MARGIN + 3, y);
doc.setFont("helvetica", "bold");
doc.setTextColor(...FG);
doc.text(String(value), MARGIN + 60, y);
return y + 6;
}
// ── Helper: autoTable wrapper ──────────────────────────
function addTable(columns, rows, startY) {
doc.autoTable({
startY,
head: [columns.map(c => c.header)],
body: rows,
margin: { left: MARGIN, right: MARGIN },
styles: {
font: "helvetica",
fontSize: 8,
cellPadding: 2.5,
textColor: FG,
lineColor: BORDER,
lineWidth: 0.2,
overflow: "linebreak",
},
headStyles: {
fillColor: BORDER,
textColor: ACCENT,
fontStyle: "bold",
fontSize: 7.5,
},
alternateRowStyles: { fillColor: [250, 248, 245] },
columnStyles: columns.reduce((acc, c, i) => {
if (c.width) acc[i] = { cellWidth: c.width };
return acc;
}, {}),
});
return doc.lastAutoTable.finalY + 8;
}
// ── 1. Daily Wirds summary ─────────────────────────────
cursorY = sectionHeading("Daily Awrād", cursorY);
// Build per-day totals for the range
const dayMap = {}; // { "2025-01-14": { durood: 300, istighfar: 100, ... } }
for (const e of allEntries) {
if (e.date < fromIso || e.date > toIso) continue;
if (!WIRD_META[e.wirdType]) continue;
if (!includeNafl && NAFL_WIRD[e.wirdType]) continue;
if (!includeKhatm && e.wirdType === "khatm") continue;
if (!dayMap[e.date]) dayMap[e.date] = {};
const meta = WIRD_META[e.wirdType];
if (meta && meta.type === "rating") {
dayMap[e.date][e.wirdType] = Math.max(
dayMap[e.date][e.wirdType] || 0, Number(e.value)
);
} else {
dayMap[e.date][e.wirdType] = (dayMap[e.date][e.wirdType] || 0) + Number(e.value);
}
}
// Build summary stats
const daysInRange = Object.keys(dayMap).length;
const dailyTypes = Object.keys(DAILY_WIRD);
const daysAllMet = Object.values(dayMap).filter(dm =>
dailyTypes.every(t => (dm[t] || 0) >= DAILY_WIRD[t].target)
).length;
cursorY = statRow("Date range", `${fmtDisplay(fromIso)}${fmtDisplay(toIso)}`, cursorY);
cursorY = statRow("Days with entries", daysInRange, cursorY);
cursorY = statRow("Days all targets met", daysAllMet, cursorY);
cursorY += 3;
// Per-wird totals table
const summaryRows = dailyTypes.map(type => {
const meta = DAILY_WIRD[type];
const values = Object.values(dayMap).map(dm => dm[type] || 0).filter(v => v > 0);
const total = meta.type === "rating"
? (values.length ? (values.reduce((a,b)=>a+b,0)/values.length).toFixed(1) + " avg" : "—")
: values.reduce((a,b)=>a+b,0).toLocaleString();
const days = values.length;
const target = meta.type === "rating"
? `${meta.target} / 5`
: `${meta.target.toLocaleString()} ${meta.unit}`;
const metDays = meta.type === "rating"
? values.filter(v => v >= meta.target).length
: values.filter(v => v >= meta.target).length;
return [meta.label, total, target, `${metDays} / ${days}`, days ? `${Math.round(metDays/days*100)}%` : "—"];
});
cursorY = addTable(
[
{ header: "Wird", width: 42 },
{ header: "Total / Avg", width: 30 },
{ header: "Target", width: 35 },
{ header: "Days Met", width: 25 },
{ header: "Hit %", width: 20 },
],
summaryRows,
cursorY
);
// ── 2. Detailed daily log ──────────────────────────────
if (doc.lastAutoTable.finalY > 200) {
doc.addPage();
cursorY = 18;
}
cursorY = sectionHeading("Detailed Daily Log", cursorY);
const logEntries = [...allEntries]
.filter(e => {
if (e.date < fromIso || e.date > toIso) return false;
if (!WIRD_META[e.wirdType]) return false;
if (!includeNafl && NAFL_WIRD[e.wirdType]) return false;
if (!includeKhatm && e.wirdType === "khatm") return false;
return true;
})
.sort((a, b) => b.date.localeCompare(a.date));
const HIDE_IN_LOG = new Set();
if (!includeNafl) Object.keys(NAFL_WIRD).forEach(k => HIDE_IN_LOG.add(k));
if (!includeKhatm) HIDE_IN_LOG.add("khatm");
const logRows = logEntries
.filter(e => !HIDE_IN_LOG.has(e.wirdType))
.map(e => {
const meta = WIRD_META[e.wirdType];
const cls = statusClass(e.wirdType, Number(e.value));
const vs = cls === "over" ? "above" : cls === "done" ? "met" : cls === "miss" ? "below" : "—";
return [
e.date,
meta ? meta.label : e.wirdType,
fmtValue(e.wirdType, e.value),
vs,
e.notes ? e.notes.replace(/\n/g, " ").slice(0, 60) : "",
];
});
cursorY = addTable(
[
{ header: "Date", width: 22 },
{ header: "Wird", width: 40 },
{ header: "Amount", width: 35 },
{ header: "vs Target", width: 22 },
{ header: "Notes", width: null },
],
logRows.length ? logRows : [["—", "No entries in range", "", "", ""]],
cursorY
);
// ── 3. Nafl section ────────────────────────────────────
if (includeNafl) {
if (doc.lastAutoTable.finalY > 200) { doc.addPage(); cursorY = 18; }
else { cursorY = doc.lastAutoTable.finalY + 6; }
cursorY = sectionHeading("Nafl Prayers", cursorY);
const naflRows = allEntries
.filter(e => NAFL_WIRD[e.wirdType] && e.date >= fromIso && e.date <= toIso && Number(e.value) >= 1)
.sort((a,b) => b.date.localeCompare(a.date))
.map(e => [e.date, NAFL_WIRD[e.wirdType]?.label || e.wirdType, "✓ prayed"]);
cursorY = addTable(
[
{ header: "Date", width: 28 },
{ header: "Prayer", width: 60 },
{ header: "Status", width: 30 },
],
naflRows.length ? naflRows : [["—", "No nafl prayers in range", ""]],
cursorY
);
}
// ── 4. Khatm section ───────────────────────────────────
if (includeKhatm) {
if (doc.lastAutoTable.finalY > 200) { doc.addPage(); cursorY = 18; }
else { cursorY = doc.lastAutoTable.finalY + 6; }
cursorY = sectionHeading("Khatm — Qurʾān Completions", cursorY);
const khatmEntries = allEntries
.filter(e => e.wirdType === "khatm" && e.date >= fromIso && e.date <= toIso && Number(e.value) >= 1)
.sort((a,b) => b.date.localeCompare(a.date));
const khatmRows = khatmEntries.map(e => {
let recType = "", noteText = e.notes || "";
try {
const parsed = JSON.parse(e.notes);
recType = KHATM_TYPES[parsed.type] || parsed.type || "";
noteText = parsed.notes || "";
} catch (_) {}
return [e.date, recType || "—", noteText.slice(0, 60) || "—"];
});
cursorY = addTable(
[
{ header: "Date", width: 28 },
{ header: "Type", width: 40 },
{ header: "Notes", width: null },
],
khatmRows.length ? khatmRows : [["—", "No khatms in range", ""]],
cursorY
);
}
// ── 5. Mutālaʿah section ──────────────────────────────
if (includeMotalah) {
if (doc.lastAutoTable.finalY > 200) { doc.addPage(); cursorY = 18; }
else { cursorY = doc.lastAutoTable.finalY + 6; }
cursorY = sectionHeading("Mutālaʿah — Study Sessions", cursorY);
const mInRange = motalahEntries
.filter(e => e.date >= fromIso && e.date <= toIso)
.sort((a,b) => b.date.localeCompare(a.date));
const totalMins = mInRange.reduce((s,e) => s + Number(e.durationMinutes), 0);
cursorY = statRow("Sessions", mInRange.length, cursorY);
cursorY = statRow("Total study time", `${totalMins} min (${(totalMins/60).toFixed(1)} hrs)`, cursorY);
cursorY += 3;
const motalahRows = mInRange.map(e => {
const bookList = (e.bookIds || [])
.map(id => {
const b = calibreBooks.find(b => String(b.id) === String(id));
return b ? b.title : String(id);
}).join(", ");
return [
e.date,
`${e.durationMinutes} min`,
bookList.slice(0, 50) || "—",
(e.notes || "").slice(0, 50) || "—",
];
});
cursorY = addTable(
[
{ header: "Date", width: 22 },
{ header: "Duration", width: 22 },
{ header: "Books", width: 70 },
{ header: "Notes", width: null },
],
motalahRows.length ? motalahRows : [["—", "No sessions in range", "", ""]],
cursorY
);
}
// ── Footer on every page ──────────────────────────────
const pageCount = doc.internal.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFont("helvetica", "normal");
doc.setFontSize(7);
doc.setTextColor(...MUTED);
doc.text(
`Wird Tracker · Page ${i} of ${pageCount}`,
PAGE_W / 2, 292,
{ align: "center" }
);
}
// ── Save ───────────────────────────────────────────────
const filename = `wird-${fromIso}-to-${toIso}.pdf`;
doc.save(filename);
}
})();