weekly updates

This commit is contained in:
2026-03-19 17:59:15 +00:00
parent 980f66d77d
commit d04ff6b68e
39 changed files with 2401 additions and 17448 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -77,17 +77,17 @@ function buildCommentTree(comments) {
const byId = {};
const roots = [];
comments.forEach(c => {
c.children = [];
byId[c.id] = c;
comments.forEach(comment => {
comment.children = [];
byId[comment.id] = comment;
});
comments.forEach(c => {
if (c.parent_id) {
const parent = byId[c.parent_id];
if (parent) parent.children.push(c);
comments.forEach(comment => {
if (comment.parent_id) {
const parent = byId[comment.parent_id];
if (parent) parent.children.push(comment);
} else {
roots.push(c);
roots.push(comment);
}
});
@@ -112,7 +112,7 @@ function renderComments(comments) {
}
const tree = buildCommentTree(comments);
tree.forEach(c => list.appendChild(createComment(c)));
tree.forEach(comment => list.appendChild(createComment(comment)));
}
@@ -153,8 +153,8 @@ async function postComment(author, content, parentId = null) {
function wireCommentForm() {
const form = document.getElementById("comment-form");
form.addEventListener("submit", async e => {
e.preventDefault();
form.addEventListener("submit", async event => {
event.preventDefault();
const authorInput = form.querySelector("input[name='author']");
const textarea = form.querySelector("textarea[name='content']");
@@ -167,7 +167,7 @@ function wireCommentForm() {
const ok = await postComment(author, content);
if (ok) {
textarea.value = "";
loadComments(); // same pattern as your Kanban board
loadComments();
} else {
alert("Failed to post comment");
}

View File

@@ -101,33 +101,36 @@ function populateMobileControls(items) {
};
}
document.getElementById("move-confirm").addEventListener("click", async () => {
const itemId = document.getElementById("move-item").value;
const from = document.getElementById("move-from").value;
const to = document.getElementById("move-to").value;
const moveConfirmBtn = document.getElementById("move-confirm");
if (moveConfirmBtn) {
moveConfirmBtn.addEventListener("click", async () => {
const itemId = document.getElementById("move-item").value;
const from = document.getElementById("move-from").value;
const to = document.getElementById("move-to").value;
if (!itemId || !to) {
alert("Select an item and target column");
return;
}
if (!itemId || !to) {
alert("Select an item and target column");
return;
}
if (from === to) {
alert("Item is already in that column");
return;
}
if (from === to) {
alert("Item is already in that column");
return;
}
const res = await fetch(`/api/competencies/items/${itemId}/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: to }),
const res = await fetch(`/api/competencies/items/${itemId}/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state: to }),
});
if (res.ok) {
loadBoard();
} else {
alert("Failed to move competency");
}
});
if (res.ok) {
loadBoard();
} else {
alert("Failed to move competency");
}
});
}
let draggedItemId = null;

View File

@@ -0,0 +1,557 @@
/* ============================================================
wird-tracker.js
============================================================ */
(function () {
"use strict";
const API = "/api/wird";
// type: "count" | "juz" | "min" | "rating"
const DAILY_WIRD = {
durood: { label: "Durood", type: "count", unit: "count", target: 500 },
istighfar: { label: "Istighfar", type: "count", unit: "count", target: 200 },
quran: { label: "Quran", type: "juz", unit: "juz", target: 3 },
muraqabah: { label: "Muraqabah", type: "min", unit: "min", target: 10 },
wuqoof_qalbi: { label: "Wuquf Qalbi", type: "rating", unit: "", target: 3 },
};
const WIRD_META = {
...DAILY_WIRD,
shaykh_meeting: { label: "Meeting w/ Shaykh", type: "meeting", unit: "meeting", target: 1 },
};
const RATING_LABELS = {
1: "distracted",
2: "scattered",
3: "present",
4: "attentive",
5: "absorbed",
};
const MEETING_CYCLE_DAYS = 21;
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";
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";
}
let allEntries = [];
let todayMap = {};
let meetingLog = [];
let chart = null;
document.addEventListener("DOMContentLoaded", async () => {
setTodayLabel();
await loadAll();
renderToday();
renderMeetingPanel();
renderHistory();
renderChart();
bindControls();
});
function setTodayLabel() {
$("#today-date").textContent = new Date().toLocaleDateString("en-GB", {
weekday: "long", day: "numeric", month: "long", year: "numeric",
});
}
async function loadAll() {
try {
const r = await fetch(`${API}/entries`);
if (!r.ok) throw new Error(r.status);
allEntries = await r.json();
buildTodayMap();
buildMeetingLog();
} catch (e) {
console.error("Wird API error:", e);
}
}
function buildTodayMap() {
const today = isoDate();
todayMap = {};
for (const e of allEntries) {
if (e.date !== today) continue;
// For ratings, take the latest entry (last log wins), not a sum
if (WIRD_META[e.wirdType]?.type === "rating") {
const existing = todayMap[e.wirdType];
if (!existing || e.createdAt > existing.createdAt) {
todayMap[e.wirdType] = Number(e.value);
}
} else {
todayMap[e.wirdType] = (todayMap[e.wirdType] || 0) + Number(e.value);
}
}
}
function buildMeetingLog() {
meetingLog = allEntries
.filter(e => e.wirdType === "shaykh_meeting" && Number(e.value) >= 1)
.sort((a, b) => b.date.localeCompare(a.date));
}
async function postEntry(payload) {
const r = await fetch(`${API}/entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
}
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) {
// 5 pips, filled up to current rating
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>
`;
}
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");
// Pip click handlers
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 => {
const v = parseInt(b.dataset.val);
b.classList.toggle("selected", v <= 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}`;
}
}
typeEl.addEventListener("change", updateModalFields);
$("#modal-cancel").addEventListener("click", () => modal.close());
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();
renderMeetingPanel();
renderHistory();
renderChart();
modal.close();
} catch (err) {
alert("Could not save entry: " + err.message);
}
});
function renderMeetingPanel() {
const today = isoDate();
const last = meetingLog[0] || null;
const lastDate = last ? last.date : null;
const nextDate = lastDate ? addDays(lastDate, MEETING_CYCLE_DAYS) : null;
const daysToNext = nextDate ? daysBetween(today, nextDate) : null;
let statusHtml = "";
if (!lastDate) {
statusHtml = `<span class="meeting-status pending">No meetings logged yet</span>`;
} else if (daysToNext > 0) {
statusHtml = `<span class="meeting-status ok">Next due in <strong>${daysToNext}</strong> day${daysToNext !== 1 ? "s" : ""} &mdash; ${fmtDisplay(nextDate)}</span>`;
} else if (daysToNext === 0) {
statusHtml = `<span class="meeting-status due">Due today</span>`;
} else {
const ov = Math.abs(daysToNext);
statusHtml = `<span class="meeting-status overdue"><strong>${ov}</strong> day${ov !== 1 ? "s" : ""} overdue &mdash; expected ${fmtDisplay(nextDate)}</span>`;
}
const cycles = buildCycleInsights();
const cycleRows = cycles.map(c => `
<tr>
<td>${fmtDisplay(c.start)} &rarr; ${fmtDisplay(c.end)}</td>
<td>${c.met
? `<span class="badge done">✓ met — ${fmtDisplay(c.metOn)}</span>`
: c.ongoing
? `<span class="badge">ongoing</span>`
: `<span class="badge miss">✗ missed</span>`}
</td>
<td style="font-family:monospace;font-size:.7rem;color:var(--muted)">${c.met ? `day ${c.dayOfCycle}` : "—"}</td>
</tr>
`).join("");
$("#meeting-panel-body").innerHTML = `
<div id="meeting-status-row">
<div>
<span class="meeting-meta-lbl">Last meeting</span>
<span class="meeting-meta-val">${lastDate ? fmtDisplay(lastDate) : "—"}</span>
</div>
<div>
<span class="meeting-meta-lbl">Next expected</span>
<span class="meeting-meta-val">${nextDate ? fmtDisplay(nextDate) : "—"}</span>
</div>
<div id="meeting-status-msg">${statusHtml}</div>
</div>
<div id="meeting-cycles">
<h3>3-week cycles</h3>
${cycles.length === 0
? `<p style="color:var(--muted);font-size:.88rem">Log at least one meeting to see cycle insights.</p>`
: `<table class="meeting-cycle-table">
<thead><tr><th>Cycle window</th><th>Status</th><th>When</th></tr></thead>
<tbody>${cycleRows}</tbody>
</table>`}
</div>
`;
}
function buildCycleInsights() {
if (meetingLog.length === 0) return [];
const dates = [...meetingLog].reverse().map(e => e.date);
const today = isoDate();
const cycles = [];
let start = dates[0];
while (start <= today) {
const end = addDays(start, MEETING_CYCLE_DAYS - 1);
const hit = dates.find(d => d >= start && d <= end);
cycles.push({
start, end,
met: !!hit,
metOn: hit || null,
dayOfCycle: hit ? daysBetween(start, hit) + 1 : null,
ongoing: end > today,
});
start = addDays(end, 1);
}
return cycles.reverse();
}
function renderHistory() {
const tbody = $("#history-body");
tbody.innerHTML = "";
const sorted = [...allEntries]
.filter(e => e.wirdType !== "shaykh_meeting")
.sort((a, b) => b.date.localeCompare(a.date));
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" ? (meta.type === "rating" ? "below" : "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);
}
}
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 = [];
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));
const dayEntries = allEntries.filter(e => e.date === iso && e.wirdType === type);
if (isRating) {
// Use the last logged rating for the day
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 e ${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 },
// For rating chart: show labels instead of numbers
...(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);
const entries = allEntries.filter(e => e.date === iso && e.wirdType === type);
let total;
if (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);
}
if (total >= meta.target) streak++;
else if (i > 0) break;
}
return streak;
}
function bindControls() {
$("#open-log-btn").addEventListener("click", () => openModal());
$("#log-meeting-btn").addEventListener("click", openMeetingModal);
$("#trend-type").addEventListener("change", renderChart);
$("#trend-range").addEventListener("change", renderChart);
}
})();

View File

@@ -317,7 +317,7 @@ body.no-sidenotes {
.note {
position: relative;
padding: 1rem 1rem 0.9rem;
background: #fffef8;
background: --code-bg;
border-radius: 8px;
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.08),
@@ -326,6 +326,7 @@ body.no-sidenotes {
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
/* subtle randomness */
@@ -391,6 +392,7 @@ body.no-sidenotes {
font-size: 0.95rem;
line-height: 1.45;
white-space: pre-wrap;
color: --fg;
}
/* =========================
@@ -424,7 +426,7 @@ body.no-sidenotes {
#notes-form {
width: 100%;
max-width: 420px;
background: #fffef8;
background: --bg;
padding: 1.25rem 1.25rem 1.1rem;
border-radius: 10px;
box-shadow:

Binary file not shown.