updating wird frontend
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
wird-tracker.js
|
wird-tracker.js (full replacement)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
@@ -7,30 +7,42 @@
|
|||||||
|
|
||||||
const API = "/api/wird";
|
const API = "/api/wird";
|
||||||
|
|
||||||
|
// type: "count" | "juz" | "min" | "rating" | "nafl" | "khatm"
|
||||||
// type: "count" | "juz" | "min" | "rating"
|
|
||||||
const DAILY_WIRD = {
|
const DAILY_WIRD = {
|
||||||
durood: { label: "Durood", type: "count", unit: "count", target: 500 },
|
durood: { label: "Durood", type: "count", unit: "count", target: 500 },
|
||||||
istighfar: { label: "Istighfar", type: "count", unit: "count", target: 200 },
|
istighfar: { label: "Istighfar", type: "count", unit: "count", target: 200 },
|
||||||
quran: { label: "Quran", type: "juz", unit: "juz", target: 3 },
|
quran: { label: "Qurʾān", type: "juz", unit: "juz", target: 3 },
|
||||||
muraqabah: { label: "Muraqabah", type: "min", unit: "min", target: 10 },
|
muraqabah: { label: "Murāqabah", type: "min", unit: "min", target: 10 },
|
||||||
wuqoof_qalbi: { label: "Wuquf Qalbi", type: "rating", unit: "", target: 3 },
|
wuqoof_qalbi: { label: "Wuqūf Qalbī", type: "rating", unit: "", target: 3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nafl prayers — stored in wird_entries, value=1 when done
|
||||||
|
const NAFL_WIRD = {
|
||||||
|
salatul_tawbah: { label: "Ṣalāt al-Tawbah", type: "nafl", unit: "", target: 1, rakats: 2, desc: "2 rakʿāt · repentance", icon: "🤲" },
|
||||||
|
salatul_hajaat: { label: "Ṣalāt al-Ḥājah", type: "nafl", unit: "", target: 1, rakats: 2, desc: "2 rakʿāt · need & supplication", icon: "🌙" },
|
||||||
|
tahajjud: { label: "Tahajjud", type: "nafl", unit: "", target: 1, rakats: 0, desc: "night vigil", icon: "⭐" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const WIRD_META = {
|
const WIRD_META = {
|
||||||
...DAILY_WIRD,
|
...DAILY_WIRD,
|
||||||
|
...NAFL_WIRD,
|
||||||
shaykh_meeting: { label: "Meeting w/ Shaykh", type: "meeting", unit: "meeting", target: 1 },
|
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 = {
|
const RATING_LABELS = {
|
||||||
1: "distracted",
|
1: "distracted", 2: "scattered", 3: "present", 4: "attentive", 5: "absorbed",
|
||||||
2: "scattered",
|
};
|
||||||
3: "present",
|
|
||||||
4: "attentive",
|
const KHATM_TYPES = {
|
||||||
5: "absorbed",
|
tilawah: "Tilāwah",
|
||||||
|
hifz: "Ḥifẓ",
|
||||||
|
tadabbur: "Tadabbur",
|
||||||
};
|
};
|
||||||
|
|
||||||
const MEETING_CYCLE_DAYS = 21;
|
const MEETING_CYCLE_DAYS = 21;
|
||||||
|
const HEATMAP_WEEKS = 52;
|
||||||
|
|
||||||
const $ = (sel, ctx = document) => ctx.querySelector(sel);
|
const $ = (sel, ctx = document) => ctx.querySelector(sel);
|
||||||
const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)];
|
const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)];
|
||||||
@@ -53,6 +65,8 @@
|
|||||||
if (!meta) return val;
|
if (!meta) return val;
|
||||||
if (meta.type === "rating") return val ? `${RATING_LABELS[val] || val} (${val}/5)` : "—";
|
if (meta.type === "rating") return val ? `${RATING_LABELS[val] || val} (${val}/5)` : "—";
|
||||||
if (meta.type === "meeting") return val >= 1 ? "✓ attended" : "✗ missed";
|
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}`;
|
return `${Number(val).toLocaleString()} ${meta.unit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,18 +83,255 @@
|
|||||||
return "miss";
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
bookSearchInput.addEventListener("input", () => {
|
||||||
|
if (calibreBooks.length === 0) return;
|
||||||
|
renderBookResults(bookSearchInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
bookSearchInput.addEventListener("focus", () => {
|
||||||
|
if (calibreBooks.length === 0) return;
|
||||||
|
renderBookResults(bookSearchInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
bookSearchResults.addEventListener("mousedown", e => {
|
||||||
|
const item = e.target.closest(".book-result-item");
|
||||||
|
if (!item) return;
|
||||||
|
e.preventDefault(); // prevent input blur before we register click
|
||||||
|
const id = String(item.dataset.id);
|
||||||
|
if (selectedBookIds.has(id)) {
|
||||||
|
selectedBookIds.delete(id);
|
||||||
|
} else {
|
||||||
|
selectedBookIds.add(id);
|
||||||
|
}
|
||||||
|
renderSelectedChips();
|
||||||
|
renderBookResults(bookSearchInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedChipsWrap.addEventListener("click", e => {
|
||||||
|
const btn = e.target.closest("button[data-id]");
|
||||||
|
if (!btn) return;
|
||||||
|
selectedBookIds.delete(String(btn.dataset.id));
|
||||||
|
renderSelectedChips();
|
||||||
|
// Refresh dropdown if open
|
||||||
|
if (bookSearchResults.classList.contains("open")) {
|
||||||
|
renderBookResults(bookSearchInput.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
document.addEventListener("click", e => {
|
||||||
|
if (!e.target.closest(".book-search-wrap")) {
|
||||||
|
bookSearchResults.classList.remove("open");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function populateBookSelect() {
|
||||||
|
// no-op — book search handles population dynamically
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMotalahModal() {
|
||||||
|
motalahForm.reset();
|
||||||
|
motalahDateEl.value = isoDate();
|
||||||
|
selectedBookIds = new Set();
|
||||||
|
bookSearchInput.value = "";
|
||||||
|
bookSearchResults.classList.remove("open");
|
||||||
|
bookSearchResults.innerHTML = "";
|
||||||
|
renderSelectedChips();
|
||||||
|
motalahModal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
motalahForm.addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const selectedBooks = [...selectedBookIds];
|
||||||
|
const payload = {
|
||||||
|
date: motalahDateEl.value,
|
||||||
|
durationMinutes: parseInt(motalahDurEl.value),
|
||||||
|
bookIds: selectedBooks,
|
||||||
|
notes: motalahNotesEl.value.trim() || null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const r = await fetch(MOTALAH_API, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
const saved = await r.json();
|
||||||
|
motalahEntries.unshift(saved);
|
||||||
|
motalahModal.close();
|
||||||
|
renderMotalah();
|
||||||
|
} catch (err) {
|
||||||
|
alert("Error saving session: " + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#motalah-cancel").addEventListener("click", () => motalahModal.close());
|
||||||
|
$("#log-motalah-btn").addEventListener("click", openMotalahModal);
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────
|
||||||
let allEntries = [];
|
let allEntries = [];
|
||||||
let todayMap = {};
|
let todayMap = {};
|
||||||
let meetingLog = [];
|
let meetingLog = [];
|
||||||
let chart = null;
|
let chart = null;
|
||||||
|
|
||||||
|
// ── Boot ─────────────────────────────────────────────────
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
setTodayLabel();
|
setTodayLabel();
|
||||||
await loadAll();
|
await Promise.all([loadAll(), loadMotalah()]); // ← change this line
|
||||||
renderToday();
|
renderToday();
|
||||||
|
renderNafl();
|
||||||
renderMeetingPanel();
|
renderMeetingPanel();
|
||||||
|
renderKhatm();
|
||||||
renderHistory();
|
renderHistory();
|
||||||
renderChart();
|
renderChart();
|
||||||
|
renderMotalah(); // ← add this
|
||||||
|
renderHeatmap();
|
||||||
bindControls();
|
bindControls();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,7 +358,6 @@
|
|||||||
todayMap = {};
|
todayMap = {};
|
||||||
for (const e of allEntries) {
|
for (const e of allEntries) {
|
||||||
if (e.date !== today) continue;
|
if (e.date !== today) continue;
|
||||||
// For ratings, take the latest entry (last log wins), not a sum
|
|
||||||
if (WIRD_META[e.wirdType]?.type === "rating") {
|
if (WIRD_META[e.wirdType]?.type === "rating") {
|
||||||
const existing = todayMap[e.wirdType];
|
const existing = todayMap[e.wirdType];
|
||||||
if (!existing || e.createdAt > existing.createdAt) {
|
if (!existing || e.createdAt > existing.createdAt) {
|
||||||
@@ -135,6 +385,15 @@
|
|||||||
return r.json();
|
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() {
|
function renderToday() {
|
||||||
const wrap = $("#wird-cards");
|
const wrap = $("#wird-cards");
|
||||||
wrap.innerHTML = "";
|
wrap.innerHTML = "";
|
||||||
@@ -178,16 +437,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildRatingCard(meta, val) {
|
function buildRatingCard(meta, val) {
|
||||||
// 5 pips, filled up to current rating
|
|
||||||
const pips = [1,2,3,4,5].map(n => {
|
const pips = [1,2,3,4,5].map(n => {
|
||||||
const filled = val >= n;
|
const filled = val >= n;
|
||||||
const isMin = n === meta.target;
|
const isMin = n === meta.target;
|
||||||
return `<span class="card-pip ${filled ? "filled" : ""} ${isMin ? "min-marker" : ""}"
|
return `<span class="card-pip ${filled ? "filled" : ""} ${isMin ? "min-marker" : ""}"
|
||||||
title="${RATING_LABELS[n]}"></span>`;
|
title="${RATING_LABELS[n]}"></span>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
const lbl = val ? `${RATING_LABELS[val]} <span style="opacity:.45">(${val}/5)</span>` : "not logged";
|
const lbl = val ? `${RATING_LABELS[val]} <span style="opacity:.45">(${val}/5)</span>` : "not logged";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card-name">${meta.label}</div>
|
<div class="card-name">${meta.label}</div>
|
||||||
<div class="card-pips">${pips}</div>
|
<div class="card-pips">${pips}</div>
|
||||||
@@ -196,6 +452,10 @@
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// LOG MODAL (existing — unchanged logic)
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
const modal = $("#log-modal");
|
const modal = $("#log-modal");
|
||||||
const form = $("#log-form");
|
const form = $("#log-form");
|
||||||
const typeEl = $("#log-type");
|
const typeEl = $("#log-type");
|
||||||
@@ -204,15 +464,13 @@
|
|||||||
const notesEl = $("#log-notes");
|
const notesEl = $("#log-notes");
|
||||||
const ratingEl = $("#log-rating-value");
|
const ratingEl = $("#log-rating-value");
|
||||||
|
|
||||||
// Pip click handlers
|
|
||||||
let selectedRating = 0;
|
let selectedRating = 0;
|
||||||
$$("#modal-pips .modal-pip").forEach(btn => {
|
$$("#modal-pips .modal-pip").forEach(btn => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
selectedRating = parseInt(btn.dataset.val);
|
selectedRating = parseInt(btn.dataset.val);
|
||||||
ratingEl.value = selectedRating;
|
ratingEl.value = selectedRating;
|
||||||
$$("#modal-pips .modal-pip").forEach(b => {
|
$$("#modal-pips .modal-pip").forEach(b => {
|
||||||
const v = parseInt(b.dataset.val);
|
b.classList.toggle("selected", parseInt(b.dataset.val) <= selectedRating);
|
||||||
b.classList.toggle("selected", v <= selectedRating);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -240,11 +498,9 @@
|
|||||||
const type = typeEl.value;
|
const type = typeEl.value;
|
||||||
const isRating = type === "wuqoof_qalbi";
|
const isRating = type === "wuqoof_qalbi";
|
||||||
const isMeeting = type === "shaykh_meeting";
|
const isMeeting = type === "shaykh_meeting";
|
||||||
|
|
||||||
$("#value-group").style.display = (!isRating && !isMeeting) ? "" : "none";
|
$("#value-group").style.display = (!isRating && !isMeeting) ? "" : "none";
|
||||||
$("#rating-group").style.display = isRating ? "" : "none";
|
$("#rating-group").style.display = isRating ? "" : "none";
|
||||||
$("#shaykh-group").style.display = isMeeting ? "" : "none";
|
$("#shaykh-group").style.display = isMeeting ? "" : "none";
|
||||||
|
|
||||||
if (!isRating && !isMeeting) {
|
if (!isRating && !isMeeting) {
|
||||||
const meta = DAILY_WIRD[type];
|
const meta = DAILY_WIRD[type];
|
||||||
if (meta) valEl.placeholder = `Target: ${meta.target} ${meta.unit}`;
|
if (meta) valEl.placeholder = `Target: ${meta.target} ${meta.unit}`;
|
||||||
@@ -260,17 +516,12 @@
|
|||||||
const isRating = type === "wuqoof_qalbi";
|
const isRating = type === "wuqoof_qalbi";
|
||||||
const isMtg = type === "shaykh_meeting";
|
const isMtg = type === "shaykh_meeting";
|
||||||
|
|
||||||
if (isRating && !ratingEl.value) {
|
if (isRating && !ratingEl.value) { alert("Please select a rating."); return; }
|
||||||
alert("Please select a rating.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
wirdType: type,
|
wirdType: type,
|
||||||
date: dateEl.value,
|
date: dateEl.value,
|
||||||
value: isRating ? Number(ratingEl.value)
|
value: isRating ? Number(ratingEl.value) : isMtg ? 1 : Number(valEl.value),
|
||||||
: isMtg ? 1
|
|
||||||
: Number(valEl.value),
|
|
||||||
notes: notesEl.value.trim() || null,
|
notes: notesEl.value.trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -280,15 +531,90 @@
|
|||||||
buildTodayMap();
|
buildTodayMap();
|
||||||
buildMeetingLog();
|
buildMeetingLog();
|
||||||
renderToday();
|
renderToday();
|
||||||
|
renderNafl();
|
||||||
renderMeetingPanel();
|
renderMeetingPanel();
|
||||||
renderHistory();
|
renderHistory();
|
||||||
renderChart();
|
renderChart();
|
||||||
|
renderHeatmap();
|
||||||
modal.close();
|
modal.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Could not save entry: " + err.message);
|
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() {
|
function renderMeetingPanel() {
|
||||||
const today = isoDate();
|
const today = isoDate();
|
||||||
const last = meetingLog[0] || null;
|
const last = meetingLog[0] || null;
|
||||||
@@ -367,12 +693,133 @@
|
|||||||
return cycles.reverse();
|
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() {
|
function renderHistory() {
|
||||||
const tbody = $("#history-body");
|
const tbody = $("#history-body");
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
|
|
||||||
|
const HIDE_TYPES = new Set(["shaykh_meeting", ...Object.keys(NAFL_WIRD), "khatm"]);
|
||||||
const sorted = [...allEntries]
|
const sorted = [...allEntries]
|
||||||
.filter(e => e.wirdType !== "shaykh_meeting")
|
.filter(e => !HIDE_TYPES.has(e.wirdType))
|
||||||
.sort((a, b) => b.date.localeCompare(a.date));
|
.sort((a, b) => b.date.localeCompare(a.date));
|
||||||
|
|
||||||
if (sorted.length === 0) {
|
if (sorted.length === 0) {
|
||||||
@@ -385,7 +832,7 @@
|
|||||||
const cls = statusClass(e.wirdType, e.value);
|
const cls = statusClass(e.wirdType, e.value);
|
||||||
const badgeTxt = cls === "over" ? (meta.type === "rating" ? "flourishing" : "above")
|
const badgeTxt = cls === "over" ? (meta.type === "rating" ? "flourishing" : "above")
|
||||||
: cls === "done" ? (meta.type === "rating" ? "present" : "met")
|
: cls === "done" ? (meta.type === "rating" ? "present" : "met")
|
||||||
: cls === "miss" ? (meta.type === "rating" ? "below" : "below")
|
: cls === "miss" ? "below"
|
||||||
: "—";
|
: "—";
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
@@ -399,31 +846,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// TREND CHART (unchanged)
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function renderChart() {
|
function renderChart() {
|
||||||
const type = $("#trend-type").value;
|
const type = $("#trend-type").value;
|
||||||
const days = parseInt($("#trend-range").value);
|
const days = parseInt($("#trend-range").value);
|
||||||
const meta = WIRD_META[type];
|
const meta = WIRD_META[type];
|
||||||
const isRating = meta.type === "rating";
|
const isRating = meta.type === "rating";
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const labels = [];
|
const labels = [];
|
||||||
const vals = [];
|
const vals = [];
|
||||||
|
|
||||||
|
const isMotalah = type === "motalah";
|
||||||
for (let i = days - 1; i >= 0; i--) {
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
const d = new Date(now);
|
const d = new Date(now);
|
||||||
d.setDate(d.getDate() - i);
|
d.setDate(d.getDate() - i);
|
||||||
const iso = isoDate(d);
|
const iso = isoDate(d);
|
||||||
labels.push(iso.slice(5));
|
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);
|
const dayEntries = allEntries.filter(e => e.date === iso && e.wirdType === type);
|
||||||
if (isRating) {
|
if (isRating) {
|
||||||
// Use the last logged rating for the day
|
|
||||||
const last = dayEntries.sort((a,b) => a.createdAt > b.createdAt ? 1 : -1).pop();
|
const last = dayEntries.sort((a,b) => a.createdAt > b.createdAt ? 1 : -1).pop();
|
||||||
vals.push(last ? Number(last.value) : 0);
|
vals.push(last ? Number(last.value) : 0);
|
||||||
} else {
|
} else {
|
||||||
vals.push(dayEntries.reduce((s, e) => s + Number(e.value), 0));
|
vals.push(dayEntries.reduce((s, e) => s + Number(e.value), 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const nonZero = vals.filter(v => v > 0);
|
const nonZero = vals.filter(v => v > 0);
|
||||||
const avg = nonZero.length
|
const avg = nonZero.length
|
||||||
@@ -438,7 +892,7 @@
|
|||||||
$("#trend-stats").innerHTML = `
|
$("#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">${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">${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 e ${meta.target}</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>
|
<div class="stat-chip"><span class="stat-val">${nonZero.length}</span><span class="stat-lbl">days logged</span></div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
@@ -454,7 +908,6 @@
|
|||||||
const accent = cs.getPropertyValue("--wird-accent").trim() || "#7c5c3a";
|
const accent = cs.getPropertyValue("--wird-accent").trim() || "#7c5c3a";
|
||||||
const gridColor = cs.getPropertyValue("--border").trim() || "#d7d7d7";
|
const gridColor = cs.getPropertyValue("--border").trim() || "#d7d7d7";
|
||||||
const tickColor = cs.getPropertyValue("--muted").trim() || "#666";
|
const tickColor = cs.getPropertyValue("--muted").trim() || "#666";
|
||||||
|
|
||||||
const ctx = $("#trend-chart").getContext("2d");
|
const ctx = $("#trend-chart").getContext("2d");
|
||||||
if (chart) chart.destroy();
|
if (chart) chart.destroy();
|
||||||
|
|
||||||
@@ -468,19 +921,14 @@
|
|||||||
data: vals,
|
data: vals,
|
||||||
borderColor: accent,
|
borderColor: accent,
|
||||||
backgroundColor: accent + "22",
|
backgroundColor: accent + "22",
|
||||||
fill: true,
|
fill: true, tension: .35, pointRadius: 3, pointHoverRadius: 5,
|
||||||
tension: .35,
|
|
||||||
pointRadius: 3,
|
|
||||||
pointHoverRadius: 5,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Target",
|
label: "Target",
|
||||||
data: vals.map(() => meta.target),
|
data: vals.map(() => meta.target),
|
||||||
borderColor: "rgba(74,124,89,.5)",
|
borderColor: "rgba(74,124,89,.5)",
|
||||||
borderDash: [5,4],
|
borderDash: [5,4],
|
||||||
pointRadius: 0,
|
pointRadius: 0, fill: false, tension: 0,
|
||||||
fill: false,
|
|
||||||
tension: 0,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -510,7 +958,6 @@
|
|||||||
ticks: {
|
ticks: {
|
||||||
color: tickColor,
|
color: tickColor,
|
||||||
font: { family: "monospace", size: 10 },
|
font: { family: "monospace", size: 10 },
|
||||||
// For rating chart: show labels instead of numbers
|
|
||||||
...(isRating ? {
|
...(isRating ? {
|
||||||
min: 0, max: 5, stepSize: 1,
|
min: 0, max: 5, stepSize: 1,
|
||||||
callback: v => RATING_LABELS[v] || (v === 0 ? "" : v),
|
callback: v => RATING_LABELS[v] || (v === 0 ? "" : v),
|
||||||
@@ -532,26 +979,252 @@
|
|||||||
const d = new Date(now);
|
const d = new Date(now);
|
||||||
d.setDate(d.getDate() - i);
|
d.setDate(d.getDate() - i);
|
||||||
const iso = isoDate(d);
|
const iso = isoDate(d);
|
||||||
const entries = allEntries.filter(e => e.date === iso && e.wirdType === type);
|
|
||||||
let total;
|
let total;
|
||||||
if (meta.type === "rating") {
|
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();
|
const last = entries.sort((a,b) => a.createdAt > b.createdAt ? 1 : -1).pop();
|
||||||
total = last ? Number(last.value) : 0;
|
total = last ? Number(last.value) : 0;
|
||||||
} else {
|
} else {
|
||||||
total = entries.reduce((s, e) => s + Number(e.value), 0);
|
total = entries.reduce((s, e) => s + Number(e.value), 0);
|
||||||
}
|
}
|
||||||
if (total >= meta.target) streak++;
|
}
|
||||||
|
const target = type === "motalah" ? MOTALAH_TARGET : (meta ? meta.target : 1);
|
||||||
|
if (total >= target) streak++;
|
||||||
else if (i > 0) break;
|
else if (i > 0) break;
|
||||||
}
|
}
|
||||||
return streak;
|
return streak;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// HEATMAP
|
||||||
|
// Reads allEntries directly. No extra API call.
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function renderHeatmap() {
|
||||||
|
const type = $("#heatmap-type").value;
|
||||||
|
const today = new Date();
|
||||||
|
const todayIso = isoDate(today);
|
||||||
|
|
||||||
|
// Anchor: Sunday on or before today, then go back (HEATMAP_WEEKS-1) full weeks
|
||||||
|
const anchor = new Date(today);
|
||||||
|
anchor.setDate(anchor.getDate() - anchor.getDay());
|
||||||
|
const startDate = new Date(anchor);
|
||||||
|
startDate.setDate(startDate.getDate() - (HEATMAP_WEEKS - 1) * 7);
|
||||||
|
|
||||||
|
const scoreMap = buildHeatmapScores(type, isoDate(startDate), todayIso);
|
||||||
|
|
||||||
|
// ── Month labels ──────────────────────────────────────
|
||||||
|
const monthLabelEl = $("#heatmap-month-labels");
|
||||||
|
monthLabelEl.innerHTML = "";
|
||||||
|
let lastMonth = null;
|
||||||
|
// 15px per col (12px cell + 3px gap)
|
||||||
|
let colsPassed = 0;
|
||||||
|
for (let w = 0; w < HEATMAP_WEEKS; w++) {
|
||||||
|
const d = new Date(startDate);
|
||||||
|
d.setDate(d.getDate() + w * 7);
|
||||||
|
const mo = d.toLocaleDateString("en-GB", { month: "short" });
|
||||||
|
if (mo !== lastMonth) {
|
||||||
|
const lbl = document.createElement("span");
|
||||||
|
lbl.className = "hm-month-lbl";
|
||||||
|
lbl.style.paddingLeft = (w - colsPassed) * 15 + "px";
|
||||||
|
lbl.textContent = mo;
|
||||||
|
monthLabelEl.appendChild(lbl);
|
||||||
|
colsPassed = w;
|
||||||
|
lastMonth = mo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grid ─────────────────────────────────────────────
|
||||||
|
const gridEl = $("#heatmap-grid");
|
||||||
|
gridEl.innerHTML = "";
|
||||||
|
|
||||||
|
// Day-of-week label column
|
||||||
|
const dowCol = document.createElement("div");
|
||||||
|
dowCol.className = "hm-dow-col";
|
||||||
|
["", "Mon", "", "Wed", "", "Fri", ""].forEach(lbl => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "hm-dow-lbl";
|
||||||
|
el.textContent = lbl;
|
||||||
|
dowCol.appendChild(el);
|
||||||
|
});
|
||||||
|
gridEl.appendChild(dowCol);
|
||||||
|
|
||||||
|
for (let w = 0; w < HEATMAP_WEEKS; w++) {
|
||||||
|
const col = document.createElement("div");
|
||||||
|
col.className = "hm-col";
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
const cellDate = new Date(startDate);
|
||||||
|
cellDate.setDate(cellDate.getDate() + w * 7 + d);
|
||||||
|
const iso = isoDate(cellDate);
|
||||||
|
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
cell.className = "hm-cell";
|
||||||
|
|
||||||
|
if (iso > todayIso) {
|
||||||
|
cell.classList.add("hm-empty");
|
||||||
|
} else {
|
||||||
|
const score = scoreMap[iso] || 0;
|
||||||
|
cell.dataset.level = scoreToLevel(score, type);
|
||||||
|
if (iso === todayIso) cell.classList.add("hm-today");
|
||||||
|
cell.title = buildTooltip(iso, score, type);
|
||||||
|
}
|
||||||
|
col.appendChild(cell);
|
||||||
|
}
|
||||||
|
gridEl.appendChild(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats ─────────────────────────────────────────────
|
||||||
|
renderHeatmapStats(scoreMap, type, isoDate(startDate), todayIso);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeatmapScores(type, startIso, endIso) {
|
||||||
|
// Returns { "2025-01-14": score } where score is a 0–1+ fraction
|
||||||
|
// (against the relevant target) so scoreToLevel() is consistent.
|
||||||
|
const map = {};
|
||||||
|
|
||||||
|
if (type === "__all__") {
|
||||||
|
// Fraction of DAILY_WIRD targets met each day
|
||||||
|
const dayCounts = {}; // { date: { type: total } }
|
||||||
|
for (const e of allEntries) {
|
||||||
|
if (e.date < startIso || e.date > endIso) continue;
|
||||||
|
if (!DAILY_WIRD[e.wirdType]) continue;
|
||||||
|
if (!dayCounts[e.date]) dayCounts[e.date] = {};
|
||||||
|
const meta = DAILY_WIRD[e.wirdType];
|
||||||
|
if (meta.type === "rating") {
|
||||||
|
dayCounts[e.date][e.wirdType] = Math.max(
|
||||||
|
dayCounts[e.date][e.wirdType] || 0, Number(e.value)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dayCounts[e.date][e.wirdType] = (dayCounts[e.date][e.wirdType] || 0) + Number(e.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = Object.keys(DAILY_WIRD).length;
|
||||||
|
for (const [date, typeMap] of Object.entries(dayCounts)) {
|
||||||
|
let met = 0;
|
||||||
|
for (const [t, val] of Object.entries(typeMap)) {
|
||||||
|
if (val >= DAILY_WIRD[t].target) met++;
|
||||||
|
}
|
||||||
|
map[date] = met / total;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nafl: fraction of 3 prayers done
|
||||||
|
if (type === "nafl") {
|
||||||
|
for (const e of allEntries) {
|
||||||
|
if (!NAFL_WIRD[e.wirdType]) continue;
|
||||||
|
if (e.date < startIso || e.date > endIso) continue;
|
||||||
|
if (Number(e.value) >= 1) map[e.date] = (map[e.date] || 0) + (1 / 3);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Khatm: binary
|
||||||
|
if (type === "khatm") {
|
||||||
|
for (const e of allEntries) {
|
||||||
|
if (e.wirdType !== "khatm") continue;
|
||||||
|
if (e.date < startIso || e.date > endIso) continue;
|
||||||
|
if (Number(e.value) >= 1) map[e.date] = 1;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Motalah: use motalahEntries, normalised against MOTALAH_TARGET
|
||||||
|
if (type === "motalah") {
|
||||||
|
for (const e of motalahEntries) {
|
||||||
|
if (e.date < startIso || e.date > endIso) continue;
|
||||||
|
map[e.date] = (map[e.date] || 0) + Number(e.durationMinutes);
|
||||||
|
}
|
||||||
|
for (const d of Object.keys(map)) map[d] = map[d] / MOTALAH_TARGET;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single wird type — normalised against target
|
||||||
|
const meta = WIRD_META[type];
|
||||||
|
for (const e of allEntries) {
|
||||||
|
if (e.wirdType !== type) continue;
|
||||||
|
if (e.date < startIso || e.date > endIso) continue;
|
||||||
|
if (meta && meta.type === "rating") {
|
||||||
|
map[e.date] = Math.max(map[e.date] || 0, Number(e.value));
|
||||||
|
} else {
|
||||||
|
map[e.date] = (map[e.date] || 0) + Number(e.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (meta && meta.target) {
|
||||||
|
for (const d of Object.keys(map)) map[d] = map[d] / meta.target;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreToLevel(score, type) {
|
||||||
|
if (type === "khatm") return score > 0 ? 4 : 0;
|
||||||
|
if (score <= 0) return 0;
|
||||||
|
if (score < 0.4) return 1;
|
||||||
|
if (score < 0.75) return 2;
|
||||||
|
if (score < 1.0) return 3;
|
||||||
|
return 4; // met or exceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTooltip(iso, score, type) {
|
||||||
|
const label = fmtDisplay(iso);
|
||||||
|
if (score <= 0) return `${label}: nothing logged`;
|
||||||
|
if (type === "__all__") return `${label}: ${Math.round(score * 100)}% of targets met`;
|
||||||
|
if (type === "nafl") return `${label}: ${Math.round(score * 3)} / 3 prayers`;
|
||||||
|
if (type === "khatm") return `${label}: khatm completed ✓`;
|
||||||
|
if (type === "motalah") return `${label}: ${Math.round(score * MOTALAH_TARGET)} min`;
|
||||||
|
const meta = WIRD_META[type];
|
||||||
|
if (!meta) return label;
|
||||||
|
if (meta.type === "rating") {
|
||||||
|
const r = Math.round(score);
|
||||||
|
return `${label}: ${RATING_LABELS[r] || r}/5`;
|
||||||
|
}
|
||||||
|
const raw = score * (meta.target || 1);
|
||||||
|
return `${label}: ${Math.round(raw).toLocaleString()} ${meta.unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeatmapStats(scoreMap, type, startIso, endIso) {
|
||||||
|
const el = $("#heatmap-stats");
|
||||||
|
const daysLogged = Object.values(scoreMap).filter(v => v > 0).length;
|
||||||
|
const daysTarget = Object.values(scoreMap).filter(v => v >= 1).length;
|
||||||
|
|
||||||
|
// Longest streak of any-activity
|
||||||
|
let longest = 0, cur = 0;
|
||||||
|
const s = new Date(startIso), e = new Date(endIso);
|
||||||
|
for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
|
||||||
|
if ((scoreMap[isoDate(d)] || 0) > 0) { cur++; longest = Math.max(longest, cur); }
|
||||||
|
else cur = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chips = [
|
||||||
|
{ val: daysLogged, lbl: "days with activity" },
|
||||||
|
{ val: `${longest}d`, lbl: "longest streak" },
|
||||||
|
];
|
||||||
|
if (type !== "khatm") {
|
||||||
|
chips.push({ val: daysTarget, lbl: type === "nafl" ? "days all prayers done" : "days target met" });
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = chips.map(c =>
|
||||||
|
`<div class="stat-chip">
|
||||||
|
<span class="stat-val">${c.val}</span>
|
||||||
|
<span class="stat-lbl">${c.lbl}</span>
|
||||||
|
</div>`
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// BIND CONTROLS
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function bindControls() {
|
function bindControls() {
|
||||||
$("#open-log-btn").addEventListener("click", () => openModal());
|
$("#open-log-btn").addEventListener("click", () => openModal());
|
||||||
$("#log-meeting-btn").addEventListener("click", openMeetingModal);
|
$("#log-meeting-btn").addEventListener("click", openMeetingModal);
|
||||||
$("#trend-type").addEventListener("change", renderChart);
|
$("#trend-type").addEventListener("change", renderChart);
|
||||||
$("#trend-range").addEventListener("change", renderChart);
|
$("#trend-range").addEventListener("change", renderChart);
|
||||||
|
$("#heatmap-type").addEventListener("change", renderHeatmap);
|
||||||
}
|
}
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -2,7 +2,7 @@
|
|||||||
#+OPTIONS: toc:nil num:nil
|
#+OPTIONS: toc:nil num:nil
|
||||||
|
|
||||||
* Recently Updated (top 26 files)
|
* Recently Updated (top 26 files)
|
||||||
- [[file:home/wird-tracker.org][Wird Tracker]] @@html:<span class="post-date">2026-03-19 18:26</span>@@
|
- [[file:home/wird-tracker.org][Wird Tracker]] @@html:<span class="post-date">2026-03-21 17:34</span>@@
|
||||||
- [[file:blogs/2026/03-march/setting-a-wird-tracker-19-03.org][Setting a Wird Tracker]] @@html:<span class="post-date">2026-03-19 13:15</span>@@
|
- [[file:blogs/2026/03-march/setting-a-wird-tracker-19-03.org][Setting a Wird Tracker]] @@html:<span class="post-date">2026-03-19 13:15</span>@@
|
||||||
- [[file:home/guide/wird-tracker-guide.org][Wird Tracker — Technical Guide]] @@html:<span class="post-date">2026-03-19 12:43</span>@@
|
- [[file:home/guide/wird-tracker-guide.org][Wird Tracker — Technical Guide]] @@html:<span class="post-date">2026-03-19 12:43</span>@@
|
||||||
- [[file:blogs/2026/03-march/fixing-the-dag-18-03.org][DAG fixes]] @@html:<span class="post-date">2026-03-18 15:01</span>@@
|
- [[file:blogs/2026/03-march/fixing-the-dag-18-03.org][DAG fixes]] @@html:<span class="post-date">2026-03-18 15:01</span>@@
|
||||||
|
|||||||
@@ -22,6 +22,43 @@ This page tracks my daily awrād and spiritual practices.
|
|||||||
<div id="wird-cards"></div>
|
<div id="wird-cards"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Nafl Prayers Panel -->
|
||||||
|
<section id="nafl-panel" class="wird-panel">
|
||||||
|
<div class="wird-panel-header">
|
||||||
|
<h2>Nafl Prayers</h2>
|
||||||
|
</div>
|
||||||
|
<div id="nafl-cards">
|
||||||
|
|
||||||
|
<div class="nafl-card" id="nafl-salatul_tawbah">
|
||||||
|
<div class="nafl-icon">🤲</div>
|
||||||
|
<div class="nafl-name">Ṣalāt al-Tawbah</div>
|
||||||
|
<div class="nafl-sub">2 rakʿāt · repentance</div>
|
||||||
|
<button class="nafl-toggle" data-type="salatul_tawbah">Mark as prayed</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nafl-card" id="nafl-salatul_hajaat">
|
||||||
|
<div class="nafl-icon">🌙</div>
|
||||||
|
<div class="nafl-name">Ṣalāt al-Ḥājah</div>
|
||||||
|
<div class="nafl-sub">2 rakʿāt · need & supplication</div>
|
||||||
|
<button class="nafl-toggle" data-type="salatul_hajaat">Mark as prayed</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nafl-card" id="nafl-tahajjud">
|
||||||
|
<div class="nafl-icon">⭐</div>
|
||||||
|
<div class="nafl-name">Tahajjud</div>
|
||||||
|
<div class="nafl-sub">night vigil prayer</div>
|
||||||
|
<button class="nafl-toggle" data-type="tahajjud">Mark as prayed</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div id="nafl-completion-bar-wrap">
|
||||||
|
<div id="nafl-bar-track">
|
||||||
|
<div id="nafl-completion-bar"></div>
|
||||||
|
</div>
|
||||||
|
<span id="nafl-completion-label">0 / 3 today</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Shaykh Meeting Panel -->
|
<!-- Shaykh Meeting Panel -->
|
||||||
<section id="meeting-panel" class="wird-panel">
|
<section id="meeting-panel" class="wird-panel">
|
||||||
<div class="wird-panel-header">
|
<div class="wird-panel-header">
|
||||||
@@ -33,10 +70,74 @@ This page tracks my daily awrād and spiritual practices.
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Khatm Tracker -->
|
||||||
|
<section id="khatm-panel" class="wird-panel">
|
||||||
|
<div class="wird-panel-header">
|
||||||
|
<h2>Khatm — Qurʾān Completions</h2>
|
||||||
|
<button id="log-khatm-btn" class="btn-primary">+ Log Khatm</button>
|
||||||
|
</div>
|
||||||
|
<div id="khatm-stats-row">
|
||||||
|
<div class="stat-chip">
|
||||||
|
<span class="stat-val" id="khatm-total">—</span>
|
||||||
|
<span class="stat-lbl">total khatms</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-chip">
|
||||||
|
<span class="stat-val" id="khatm-this-year">—</span>
|
||||||
|
<span class="stat-lbl">this year</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-chip">
|
||||||
|
<span class="stat-val" id="khatm-avg-days">—</span>
|
||||||
|
<span class="stat-lbl">avg days between</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-chip">
|
||||||
|
<span class="stat-val" id="khatm-last">—</span>
|
||||||
|
<span class="stat-lbl">last completed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="khatm-list">
|
||||||
|
<p style="color:var(--muted);font-style:italic">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Khatm Modal -->
|
||||||
|
<dialog id="khatm-modal">
|
||||||
|
<form id="khatm-form" method="dialog">
|
||||||
|
<h3>Log Khatm</h3>
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="khatm-date">Date completed</label>
|
||||||
|
<input type="date" id="khatm-date" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="khatm-recitation">Recitation type <span class="optional">(optional)</span></label>
|
||||||
|
<select id="khatm-recitation">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
<option value="tilawah">Tilāwah (reading)</option>
|
||||||
|
<option value="hifz">Ḥifẓ (memorisation)</option>
|
||||||
|
<option value="tadabbur">Tadabbur (reflection)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="khatm-notes">Notes <span class="optional">(optional)</span></label>
|
||||||
|
<textarea id="khatm-notes" rows="3" placeholder="Any reflections…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div id="khatm-modal-actions">
|
||||||
|
<button type="button" id="khatm-cancel">Cancel</button>
|
||||||
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<!-- Log Entry Modal -->
|
<!-- Log Entry Modal -->
|
||||||
<dialog id="log-modal">
|
<dialog id="log-modal">
|
||||||
<form id="log-form" method="dialog">
|
<form id="log-form" method="dialog">
|
||||||
<h3 id="modal-title">Log Entry</h3>
|
<h3 id="modal-title">Log Entry</h3>
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label for="log-date">Date</label>
|
<label for="log-date">Date</label>
|
||||||
@@ -91,14 +192,15 @@ This page tracks my daily awrād and spiritual practices.
|
|||||||
|
|
||||||
<!-- Shaykh meeting note -->
|
<!-- Shaykh meeting note -->
|
||||||
<div class="field-group" id="shaykh-group" style="display:none">
|
<div class="field-group" id="shaykh-group" style="display:none">
|
||||||
<p style="font-size:.82rem;color:var(--muted);margin:0">Set the date above to when the meeting took place.</p>
|
<p style="font-size:.85rem;color:var(--muted);margin:0">Set the date above to when the meeting took place.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<label for="log-notes">Notes <span class="optional">(optional)</span></label>
|
<label for="log-notes">Notes <span class="optional">(optional)</span></label>
|
||||||
<textarea id="log-notes" rows="2" placeholder="Any reflections…"></textarea>
|
<textarea id="log-notes" rows="3" placeholder="Any reflections…"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
<div id="modal-actions">
|
<div id="modal-actions">
|
||||||
<button type="button" id="modal-cancel">Cancel</button>
|
<button type="button" id="modal-cancel">Cancel</button>
|
||||||
<button type="submit" id="modal-save" class="btn-primary">Save</button>
|
<button type="submit" id="modal-save" class="btn-primary">Save</button>
|
||||||
@@ -106,6 +208,112 @@ This page tracks my daily awrād and spiritual practices.
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Mutāla'ah (Study) Panel -->
|
||||||
|
<section id="motalah-panel" class="wird-panel">
|
||||||
|
<div class="wird-panel-header">
|
||||||
|
<h2>Mutālaʿah — Study</h2>
|
||||||
|
<button id="log-motalah-btn" class="btn-primary">+ Log Session</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Today summary -->
|
||||||
|
<div id="motalah-today-row">
|
||||||
|
<div class="motalah-today-stat">
|
||||||
|
<span class="motalah-today-val" id="motalah-today-mins">—</span>
|
||||||
|
<span class="motalah-today-lbl">min today</span>
|
||||||
|
</div>
|
||||||
|
<div class="motalah-today-stat">
|
||||||
|
<span class="motalah-today-val" id="motalah-streak">—</span>
|
||||||
|
<span class="motalah-today-lbl">day streak</span>
|
||||||
|
</div>
|
||||||
|
<div class="motalah-today-stat">
|
||||||
|
<span class="motalah-today-val" id="motalah-total-hrs">—</span>
|
||||||
|
<span class="motalah-today-lbl">hrs this month</span>
|
||||||
|
</div>
|
||||||
|
<div class="motalah-today-stat">
|
||||||
|
<span class="motalah-today-val" id="motalah-books-count">—</span>
|
||||||
|
<span class="motalah-today-lbl">books touched</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent sessions -->
|
||||||
|
<div id="motalah-sessions-wrap">
|
||||||
|
<p style="color:var(--muted);font-style:italic">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Mutāla'ah Log Modal -->
|
||||||
|
<dialog id="motalah-modal">
|
||||||
|
<form id="motalah-form" method="dialog">
|
||||||
|
<h3>Log Study Session</h3>
|
||||||
|
<div class="modal-body">
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="motalah-date">Date</label>
|
||||||
|
<input type="date" id="motalah-date" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="motalah-duration">Duration (minutes)</label>
|
||||||
|
<input type="number" id="motalah-duration" min="1" step="1" placeholder="e.g. 45" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label>Books covered <span class="optional">(optional)</span></label>
|
||||||
|
<div class="book-search-wrap">
|
||||||
|
<span class="book-search-icon">🔍</span>
|
||||||
|
<input type="text" id="motalah-book-search" placeholder="Search your library…" autocomplete="off">
|
||||||
|
<div id="book-search-results"></div>
|
||||||
|
</div>
|
||||||
|
<div id="selected-books-chips"></div>
|
||||||
|
<!-- Hidden to carry selected IDs for JS -->
|
||||||
|
<select id="motalah-books" multiple style="display:none"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="motalah-notes">Notes <span class="optional">(optional)</span></label>
|
||||||
|
<textarea id="motalah-notes" rows="3" placeholder="What did you cover?"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div id="motalah-modal-actions">
|
||||||
|
<button type="button" id="motalah-cancel">Cancel</button>
|
||||||
|
<button type="submit" id="motalah-save" class="btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Heatmap -->
|
||||||
|
<section id="heatmap-panel" class="wird-panel">
|
||||||
|
<div class="wird-panel-header">
|
||||||
|
<h2>Consistency</h2>
|
||||||
|
<select id="heatmap-type">
|
||||||
|
<option value="__all__">All wirds</option>
|
||||||
|
<option value="durood">Durood</option>
|
||||||
|
<option value="istighfar">Istighfar</option>
|
||||||
|
<option value="quran">Qurʾān</option>
|
||||||
|
<option value="muraqabah">Murāqabah</option>
|
||||||
|
<option value="wuqoof_qalbi">Wuqūf Qalbī</option>
|
||||||
|
<option value="nafl">Nafl prayers</option>
|
||||||
|
<option value="khatm">Khatm</option>
|
||||||
|
<option value="motalah">Mutālaʿah</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="heatmap-wrap">
|
||||||
|
<div id="heatmap-month-labels"></div>
|
||||||
|
<div id="heatmap-grid"></div>
|
||||||
|
<div id="heatmap-legend">
|
||||||
|
<span class="hm-legend-lbl">less</span>
|
||||||
|
<span class="hm-swatch" data-level="0"></span>
|
||||||
|
<span class="hm-swatch" data-level="1"></span>
|
||||||
|
<span class="hm-swatch" data-level="2"></span>
|
||||||
|
<span class="hm-swatch" data-level="3"></span>
|
||||||
|
<span class="hm-swatch" data-level="4"></span>
|
||||||
|
<span class="hm-legend-lbl">more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="heatmap-stats"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Trends -->
|
<!-- Trends -->
|
||||||
<section id="trends-panel" class="wird-panel">
|
<section id="trends-panel" class="wird-panel">
|
||||||
<div class="wird-panel-header">
|
<div class="wird-panel-header">
|
||||||
@@ -117,6 +325,7 @@ This page tracks my daily awrād and spiritual practices.
|
|||||||
<option value="quran">Qurʾān</option>
|
<option value="quran">Qurʾān</option>
|
||||||
<option value="muraqabah">Murāqabah</option>
|
<option value="muraqabah">Murāqabah</option>
|
||||||
<option value="wuqoof_qalbi">Wuqūf Qalbī</option>
|
<option value="wuqoof_qalbi">Wuqūf Qalbī</option>
|
||||||
|
<option value="motalah">Mutālaʿah</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="trend-range">
|
<select id="trend-range">
|
||||||
<option value="14">14 days</option>
|
<option value="14">14 days</option>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ emacs -Q --script build-site.el
|
|||||||
[33m WARN [0mClearing .org-timestamps/ and forcing full republish
|
[33m WARN [0mClearing .org-timestamps/ and forcing full republish
|
||||||
[2m[1m── Tag generation ──────────────────────────────────────────────[0m
|
[2m[1m── Tag generation ──────────────────────────────────────────────[0m
|
||||||
[32m[1m ✓ [0mTag pages written (11 tags)
|
[32m[1m ✓ [0mTag pages written (11 tags)
|
||||||
[35m ◈ [0mtag page generation took 1.12s
|
[35m ◈ [0mtag page generation took 1.14s
|
||||||
[2m[1m── Publishing ──────────────────────────────────────────────────[0m
|
[2m[1m── Publishing ──────────────────────────────────────────────────[0m
|
||||||
[36m INFO [0mSITE_FORCE=1 — forcing republish of all files
|
[36m INFO [0mSITE_FORCE=1 — forcing republish of all files
|
||||||
Resetting org-publish-cache
|
Resetting org-publish-cache
|
||||||
@@ -1530,9 +1530,9 @@ Resetting org-publish-cache
|
|||||||
Generating sitemap for Lima
|
Generating sitemap for Lima
|
||||||
Publishing file /home/zaine/master-folder/org_files/org_web/lima/index.md using ‘z/publish-lima-file’
|
Publishing file /home/zaine/master-folder/org_files/org_web/lima/index.md using ‘z/publish-lima-file’
|
||||||
Publishing file /home/zaine/master-folder/org_files/org_web/lima/lima-list.org using ‘z/publish-lima-file’
|
Publishing file /home/zaine/master-folder/org_files/org_web/lima/lima-list.org using ‘z/publish-lima-file’
|
||||||
[35m ◈ [0morg-publish-all took 18.89s
|
[35m ◈ [0morg-publish-all took 19.18s
|
||||||
[2m[1m── Build summary ───────────────────────────────────────────────[0m
|
[2m[1m── Build summary ───────────────────────────────────────────────[0m
|
||||||
[35m ◈ [0mTotal elapsed : 20.46s
|
[35m ◈ [0mTotal elapsed : 20.77s
|
||||||
[35m ◈ [0mHTML files output: 88
|
[35m ◈ [0mHTML files output: 88
|
||||||
[35m ◈ [0mCSS files output: 9
|
[35m ◈ [0mCSS files output: 9
|
||||||
[35m ◈ [0mJS files output: 11
|
[35m ◈ [0mJS files output: 11
|
||||||
@@ -1545,10 +1545,10 @@ Collecting beautifulsoup4
|
|||||||
Using cached beautifulsoup4-4.14.3-py3-none-any.whl (107 kB)
|
Using cached beautifulsoup4-4.14.3-py3-none-any.whl (107 kB)
|
||||||
Collecting lxml
|
Collecting lxml
|
||||||
Using cached lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl (5.3 MB)
|
Using cached lxml-6.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl (5.3 MB)
|
||||||
Collecting soupsieve>=1.6.1
|
|
||||||
Using cached soupsieve-2.8.3-py3-none-any.whl (37 kB)
|
|
||||||
Collecting typing-extensions>=4.0.0
|
Collecting typing-extensions>=4.0.0
|
||||||
Using cached typing_extensions-4.15.0-py3-none-any.whl (44 kB)
|
Using cached typing_extensions-4.15.0-py3-none-any.whl (44 kB)
|
||||||
|
Collecting soupsieve>=1.6.1
|
||||||
|
Using cached soupsieve-2.8.3-py3-none-any.whl (37 kB)
|
||||||
Installing collected packages: typing-extensions, soupsieve, lxml, beautifulsoup4
|
Installing collected packages: typing-extensions, soupsieve, lxml, beautifulsoup4
|
||||||
Successfully installed beautifulsoup4-4.14.3 lxml-6.0.2 soupsieve-2.8.3 typing-extensions-4.15.0
|
Successfully installed beautifulsoup4-4.14.3 lxml-6.0.2 soupsieve-2.8.3 typing-extensions-4.15.0
|
||||||
Generating search index
|
Generating search index
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
||||||
|
|
||||||
* Posts:
|
* Posts:
|
||||||
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">20-03-2026 23:55</span>@@
|
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">21-03-2026 17:37</span>@@
|
||||||
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||||
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||||
- [[file:career/restful-api.org][Restful API]] @@html:<span class="post-date">15-02-2026 23:00</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
- [[file:career/restful-api.org][Restful API]] @@html:<span class="post-date">15-02-2026 23:00</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||||
|
|||||||
@@ -43,9 +43,9 @@
|
|||||||
- [[file:tags/website.org][Tag: website]]
|
- [[file:tags/website.org][Tag: website]]
|
||||||
- [[file:tags/insights.org][Tag: insights]]
|
- [[file:tags/insights.org][Tag: insights]]
|
||||||
- [[file:tags/life.org][Tag: life]]
|
- [[file:tags/life.org][Tag: life]]
|
||||||
- [[file:tags/emacs.org][Tag: emacs]]
|
|
||||||
- [[file:tags/education.org][Tag: education]]
|
- [[file:tags/education.org][Tag: education]]
|
||||||
- [[file:tags/reading.org][Tag: reading]]
|
- [[file:tags/reading.org][Tag: reading]]
|
||||||
|
- [[file:tags/emacs.org][Tag: emacs]]
|
||||||
- [[file:tags/maths.org][Tag: maths]]
|
- [[file:tags/maths.org][Tag: maths]]
|
||||||
- home
|
- home
|
||||||
- [[file:home/countdown.org][Countdown]]
|
- [[file:home/countdown.org][Countdown]]
|
||||||
|
|||||||
Reference in New Issue
Block a user