1790 lines
62 KiB
JavaScript
Executable File
1790 lines
62 KiB
JavaScript
Executable File
/* ============================================================
|
||
wird-tracker.js (full replacement)
|
||
============================================================ */
|
||
|
||
(function () {
|
||
"use strict";
|
||
|
||
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([
|
||
window.orgAuth.fetch(MOTALAH_API),
|
||
window.orgAuth.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 window.orgAuth.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 () => {
|
||
if (!document.getElementById("wird-app")) return;
|
||
|
||
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 window.orgAuth.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 window.orgAuth.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 window.orgAuth.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" : ""} — ${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 — expected ${fmtDisplay(nextDate)}</span>`;
|
||
}
|
||
|
||
const cycles = buildCycleInsights();
|
||
const cycleRows = cycles.map(c => `
|
||
<tr>
|
||
<td>${fmtDisplay(c.start)} → ${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 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 `<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);
|
||
}
|
||
})();
|