weekly updates
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
557
assets/scripts/wird-tracker.js
Normal file
557
assets/scripts/wird-tracker.js
Normal 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" : ""} — ${fmtDisplay(nextDate)}</span>`;
|
||||
} else if (daysToNext === 0) {
|
||||
statusHtml = `<span class="meeting-status due">Due today</span>`;
|
||||
} else {
|
||||
const ov = Math.abs(daysToNext);
|
||||
statusHtml = `<span class="meeting-status overdue"><strong>${ov}</strong> day${ov !== 1 ? "s" : ""} overdue — expected ${fmtDisplay(nextDate)}</span>`;
|
||||
}
|
||||
|
||||
const cycles = buildCycleInsights();
|
||||
const cycleRows = cycles.map(c => `
|
||||
<tr>
|
||||
<td>${fmtDisplay(c.start)} → ${fmtDisplay(c.end)}</td>
|
||||
<td>${c.met
|
||||
? `<span class="badge done">✓ met — ${fmtDisplay(c.metOn)}</span>`
|
||||
: c.ongoing
|
||||
? `<span class="badge">ongoing</span>`
|
||||
: `<span class="badge miss">✗ missed</span>`}
|
||||
</td>
|
||||
<td style="font-family:monospace;font-size:.7rem;color:var(--muted)">${c.met ? `day ${c.dayOfCycle}` : "—"}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
$("#meeting-panel-body").innerHTML = `
|
||||
<div id="meeting-status-row">
|
||||
<div>
|
||||
<span class="meeting-meta-lbl">Last meeting</span>
|
||||
<span class="meeting-meta-val">${lastDate ? fmtDisplay(lastDate) : "—"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="meeting-meta-lbl">Next expected</span>
|
||||
<span class="meeting-meta-val">${nextDate ? fmtDisplay(nextDate) : "—"}</span>
|
||||
</div>
|
||||
<div id="meeting-status-msg">${statusHtml}</div>
|
||||
</div>
|
||||
<div id="meeting-cycles">
|
||||
<h3>3-week cycles</h3>
|
||||
${cycles.length === 0
|
||||
? `<p style="color:var(--muted);font-size:.88rem">Log at least one meeting to see cycle insights.</p>`
|
||||
: `<table class="meeting-cycle-table">
|
||||
<thead><tr><th>Cycle window</th><th>Status</th><th>When</th></tr></thead>
|
||||
<tbody>${cycleRows}</tbody>
|
||||
</table>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildCycleInsights() {
|
||||
if (meetingLog.length === 0) return [];
|
||||
const dates = [...meetingLog].reverse().map(e => e.date);
|
||||
const today = isoDate();
|
||||
const cycles = [];
|
||||
let start = dates[0];
|
||||
while (start <= today) {
|
||||
const end = addDays(start, MEETING_CYCLE_DAYS - 1);
|
||||
const hit = dates.find(d => d >= start && d <= end);
|
||||
cycles.push({
|
||||
start, end,
|
||||
met: !!hit,
|
||||
metOn: hit || null,
|
||||
dayOfCycle: hit ? daysBetween(start, hit) + 1 : null,
|
||||
ongoing: end > today,
|
||||
});
|
||||
start = addDays(end, 1);
|
||||
}
|
||||
return cycles.reverse();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user