`;
tbody.appendChild(tr);
}
}
// ════════════════════════════════════════════════════════
// TREND CHART (unchanged)
// ════════════════════════════════════════════════════════
function renderChart() {
const type = $("#trend-type").value;
const days = parseInt($("#trend-range").value);
const meta = WIRD_META[type];
const isRating = meta.type === "rating";
const now = new Date();
const labels = [];
const vals = [];
const isMotalah = type === "motalah";
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const iso = isoDate(d);
labels.push(iso.slice(5));
if (isMotalah) {
vals.push(motalahEntries.filter(e => e.date === iso)
.reduce((s, e) => s + Number(e.durationMinutes), 0));
} else {
const dayEntries = allEntries.filter(e => e.date === iso && e.wirdType === type);
if (isRating) {
const last = dayEntries.sort((a, b) => a.createdAt > b.createdAt ? 1 : -1).pop();
vals.push(last ? Number(last.value) : 0);
} else {
vals.push(dayEntries.reduce((s, e) => s + Number(e.value), 0));
}
}
}
const nonZero = vals.filter(v => v > 0);
const avg = nonZero.length
? (isRating
? (nonZero.reduce((a, b) => a + b, 0) / nonZero.length).toFixed(1)
: Math.round(nonZero.reduce((a, b) => a + b, 0) / nonZero.length))
: 0;
const max = nonZero.length ? Math.max(...nonZero) : 0;
const streak = calcStreak(type);
if (isRating) {
$("#trend-stats").innerHTML = `
${avg}avg rating
${RATING_LABELS[max] || "—"}best day
${streak}dstreak ≥ ${meta.target}
${nonZero.length}days logged
`;
} else {
$("#trend-stats").innerHTML = `
${Number(avg).toLocaleString()}avg / day
${Number(max).toLocaleString()}best day
${streak}dcurrent streak
${meta.target.toLocaleString()}daily target
`;
}
const cs = getComputedStyle(document.documentElement);
const accent = cs.getPropertyValue("--wird-accent").trim() || "#7c5c3a";
const gridColor = cs.getPropertyValue("--border").trim() || "#d7d7d7";
const tickColor = cs.getPropertyValue("--muted").trim() || "#666";
const ctx = $("#trend-chart").getContext("2d");
if (chart) chart.destroy();
chart = new Chart(ctx, {
type: "line",
data: {
labels,
datasets: [
{
label: meta.label,
data: vals,
borderColor: accent,
backgroundColor: accent + "22",
fill: true, tension: .35, pointRadius: 3, pointHoverRadius: 5,
},
{
label: "Target",
data: vals.map(() => meta.target),
borderColor: "rgba(74,124,89,.5)",
borderDash: [5, 4],
pointRadius: 0, fill: false, tension: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => {
const v = ctx.parsed.y;
if (isRating && ctx.datasetIndex === 0)
return `${meta.label}: ${RATING_LABELS[v] || v} (${v}/5)`;
return `${ctx.dataset.label}: ${v}`;
},
},
},
},
scales: {
x: {
grid: { color: gridColor + "22" },
ticks: { color: tickColor, font: { family: "monospace", size: 10 }, maxTicksLimit: 10 },
},
y: {
grid: { color: gridColor + "22" },
ticks: {
color: tickColor,
font: { family: "monospace", size: 10 },
...(isRating ? {
min: 0, max: 5, stepSize: 1,
callback: v => RATING_LABELS[v] || (v === 0 ? "" : v),
} : {}),
},
beginAtZero: true,
...(isRating ? { min: 0, max: 5 } : {}),
},
},
},
});
}
function calcStreak(type) {
const meta = WIRD_META[type];
let streak = 0;
const now = new Date();
for (let i = 0; i < 365; i++) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const iso = isoDate(d);
let total;
if (type === "motalah") {
total = motalahEntries.filter(e => e.date === iso)
.reduce((s, e) => s + Number(e.durationMinutes), 0);
} else {
const entries = allEntries.filter(e => e.date === iso && e.wirdType === type);
if (meta && meta.type === "rating") {
const last = entries.sort((a, b) => a.createdAt > b.createdAt ? 1 : -1).pop();
total = last ? Number(last.value) : 0;
} else {
total = entries.reduce((s, e) => s + Number(e.value), 0);
}
}
const target = type === "motalah" ? MOTALAH_TARGET : (meta ? meta.target : 1);
if (total >= target) streak++;
else if (i > 0) break;
}
return streak;
}
// ════════════════════════════════════════════════════════
// HEATMAP
// Reads allEntries directly. No extra API call.
// ════════════════════════════════════════════════════════
function renderHeatmap() {
const type = $("#heatmap-type").value;
const today = new Date();
const todayIso = isoDate(today);
// Anchor: Sunday on or before today, then go back (HEATMAP_WEEKS-1) full weeks
const anchor = new Date(today);
anchor.setDate(anchor.getDate() - anchor.getDay());
const startDate = new Date(anchor);
startDate.setDate(startDate.getDate() - (HEATMAP_WEEKS - 1) * 7);
const scoreMap = buildHeatmapScores(type, isoDate(startDate), todayIso);
// ── Month labels ──────────────────────────────────────
const monthLabelEl = $("#heatmap-month-labels");
monthLabelEl.innerHTML = "";
let lastMonth = null;
// 15px per col (12px cell + 3px gap); labels are position:absolute
for (let w = 0; w < HEATMAP_WEEKS; w++) {
const d = new Date(startDate);
d.setDate(d.getDate() + w * 7);
const mo = d.toLocaleDateString("en-GB", { month: "short" });
if (mo !== lastMonth) {
const lbl = document.createElement("span");
lbl.className = "hm-month-lbl";
lbl.style.left = w * 15 + "px";
lbl.textContent = mo;
monthLabelEl.appendChild(lbl);
lastMonth = mo;
}
}
// ── Grid ─────────────────────────────────────────────
const gridEl = $("#heatmap-grid");
gridEl.innerHTML = "";
// Day-of-week label column
const dowCol = document.createElement("div");
dowCol.className = "hm-dow-col";
["", "Mon", "", "Wed", "", "Fri", ""].forEach(lbl => {
const el = document.createElement("div");
el.className = "hm-dow-lbl";
el.textContent = lbl;
dowCol.appendChild(el);
});
gridEl.appendChild(dowCol);
for (let w = 0; w < HEATMAP_WEEKS; w++) {
const col = document.createElement("div");
col.className = "hm-col";
for (let d = 0; d < 7; d++) {
const cellDate = new Date(startDate);
cellDate.setDate(cellDate.getDate() + w * 7 + d);
const iso = isoDate(cellDate);
const cell = document.createElement("div");
cell.className = "hm-cell";
if (iso > todayIso) {
cell.classList.add("hm-empty");
} else {
const score = scoreMap[iso] || 0;
cell.dataset.level = scoreToLevel(score, type);
if (iso === todayIso) cell.classList.add("hm-today");
cell.title = buildTooltip(iso, score, type);
}
col.appendChild(cell);
}
gridEl.appendChild(col);
}
// ── Stats ─────────────────────────────────────────────
renderHeatmapStats(scoreMap, type, isoDate(startDate), todayIso);
}
function buildHeatmapScores(type, startIso, endIso) {
// Returns { "2025-01-14": score } where score is a 0–1+ fraction
// (against the relevant target) so scoreToLevel() is consistent.
const map = {};
if (type === "__all__") {
// Fraction of DAILY_WIRD targets met each day
const dayCounts = {}; // { date: { type: total } }
for (const e of allEntries) {
if (e.date < startIso || e.date > endIso) continue;
if (!DAILY_WIRD[e.wirdType]) continue;
if (!dayCounts[e.date]) dayCounts[e.date] = {};
const meta = DAILY_WIRD[e.wirdType];
if (meta.type === "rating") {
dayCounts[e.date][e.wirdType] = Math.max(
dayCounts[e.date][e.wirdType] || 0, Number(e.value)
);
} else {
dayCounts[e.date][e.wirdType] = (dayCounts[e.date][e.wirdType] || 0) + Number(e.value);
}
}
const total = Object.keys(DAILY_WIRD).length;
for (const [date, typeMap] of Object.entries(dayCounts)) {
let met = 0;
for (const [t, val] of Object.entries(typeMap)) {
if (val >= DAILY_WIRD[t].target) met++;
}
map[date] = met / total;
}
return map;
}
// Nafl: fraction of 3 prayers done
if (type === "nafl") {
for (const e of allEntries) {
if (!NAFL_WIRD[e.wirdType]) continue;
if (e.date < startIso || e.date > endIso) continue;
if (Number(e.value) >= 1) map[e.date] = (map[e.date] || 0) + (1 / 3);
}
return map;
}
// Khatm: binary
if (type === "khatm") {
for (const e of allEntries) {
if (e.wirdType !== "khatm") continue;
if (e.date < startIso || e.date > endIso) continue;
if (Number(e.value) >= 1) map[e.date] = 1;
}
return map;
}
// Motalah: use motalahEntries, normalised against MOTALAH_TARGET
if (type === "motalah") {
for (const e of motalahEntries) {
if (e.date < startIso || e.date > endIso) continue;
map[e.date] = (map[e.date] || 0) + Number(e.durationMinutes);
}
for (const d of Object.keys(map)) map[d] = map[d] / MOTALAH_TARGET;
return map;
}
// Single wird type — normalised against target
const meta = WIRD_META[type];
for (const e of allEntries) {
if (e.wirdType !== type) continue;
if (e.date < startIso || e.date > endIso) continue;
if (meta && meta.type === "rating") {
map[e.date] = Math.max(map[e.date] || 0, Number(e.value));
} else {
map[e.date] = (map[e.date] || 0) + Number(e.value);
}
}
if (meta && meta.target) {
for (const d of Object.keys(map)) map[d] = map[d] / meta.target;
}
return map;
}
function scoreToLevel(score, type) {
if (type === "khatm") return score > 0 ? 4 : 0;
if (score <= 0) return 0;
if (score < 0.4) return 1;
if (score < 0.75) return 2;
if (score < 1.0) return 3;
return 4; // met or exceeded
}
function buildTooltip(iso, score, type) {
const label = fmtDisplay(iso);
if (score <= 0) return `${label}: nothing logged`;
if (type === "__all__") return `${label}: ${Math.round(score * 100)}% of targets met`;
if (type === "nafl") return `${label}: ${Math.round(score * 3)} / 3 prayers`;
if (type === "khatm") return `${label}: khatm completed ✓`;
if (type === "motalah") return `${label}: ${Math.round(score * MOTALAH_TARGET)} min`;
const meta = WIRD_META[type];
if (!meta) return label;
if (meta.type === "rating") {
const r = Math.round(score);
return `${label}: ${RATING_LABELS[r] || r}/5`;
}
const raw = score * (meta.target || 1);
return `${label}: ${Math.round(raw).toLocaleString()} ${meta.unit}`;
}
function renderHeatmapStats(scoreMap, type, startIso, endIso) {
const el = $("#heatmap-stats");
const daysLogged = Object.values(scoreMap).filter(v => v > 0).length;
const daysTarget = Object.values(scoreMap).filter(v => v >= 1).length;
// Longest streak of any-activity
let longest = 0, cur = 0;
const s = new Date(startIso), e = new Date(endIso);
for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
if ((scoreMap[isoDate(d)] || 0) > 0) { cur++; longest = Math.max(longest, cur); }
else cur = 0;
}
const chips = [
{ val: daysLogged, lbl: "days with activity" },
{ val: `${longest}d`, lbl: "longest streak" },
];
if (type !== "khatm") {
chips.push({ val: daysTarget, lbl: type === "nafl" ? "days all prayers done" : "days target met" });
}
el.innerHTML = chips.map(c =>
`
${c.val}${c.lbl}
`
).join("");
}
// ════════════════════════════════════════════════════════
// BIND CONTROLS
// ════════════════════════════════════════════════════════
function bindControls() {
$("#open-log-btn").addEventListener("click", () => openModal());
$("#log-meeting-btn").addEventListener("click", openMeetingModal);
$("#trend-type").addEventListener("change", renderChart);
$("#trend-range").addEventListener("change", renderChart);
$("#heatmap-type").addEventListener("change", renderHeatmap);
}
/* ============================================================
wird-export.js
Client-side PDF export for the Wird Tracker.
Depends on: jsPDF + jspdf-autotable (loaded via CDN).
All data is read from allEntries / motalahEntries already
held in memory by wird-tracker.js — no extra API calls.
HOW TO INTEGRATE
─────────────────
1. Paste this block INSIDE the existing IIFE in
wird-tracker.js, just before the closing })();
2. Add the HTML snippet (wird-export-html.html) to the
template and the CDN