// ── 22-trackers.js — Custom time-goal progress trackers ──
/**
* Loads the trackers array from localStorage into the module-level `trackers` variable.
* Falls back to an empty array and logs a warning on parse failure.
* @returns {void}
*/
function loadTrackers() {
try {
const raw = JSON.parse(localStorage.getItem(STORE_TRACKERS) || '[]');
trackers = Array.isArray(raw) ? raw : [];
} catch (e) {
trackers = [];
wlLog.warn('loadTrackers: failed to parse trackers from localStorage', e);
}
}
/**
* Persists the current `trackers` array to localStorage.
* @returns {void}
*/
function saveTrackers() {
localStorage.setItem(STORE_TRACKERS, JSON.stringify(trackers));
}
/**
* Returns 'hit' | 'partial' | 'miss' for a given tracker on a given day.
* @param {Object} tracker - Tracker object with tags and targetMinutes.
* @param {string} dateKey - YYYY-MM-DD date key.
* @returns {'hit'|'partial'|'miss'}
*/
function trackerDayStatus(tracker, dateKey) {
const ms = entries
.filter(
(e) =>
e.date === dateKey && tracker.tags.includes(e.tag) && e.tsEnd && e.signifier !== 'cancelled'
)
.reduce((sum, e) => sum + (e.tsEnd - e.ts), 0);
const mins = ms / 60000;
if (mins >= tracker.targetMinutes) return 'hit';
if (mins >= tracker.targetMinutes * 0.5) return 'partial';
return 'miss';
}
/**
* Calculates the current consecutive "hit" streak for a tracker, ending today.
* Walks backwards day-by-day (up to 60 days) and stops at the first non-hit day.
* @param {Object} tracker - Tracker object with `tags` and `targetMinutes`.
* @returns {number} Number of consecutive hit days ending today.
*/
function trackerStreak(tracker) {
let streak = 0;
const d = new Date();
for (let i = 0; i < 60; i++) {
const dateKey = dk(d);
if (trackerDayStatus(tracker, dateKey) === 'hit') {
streak++;
d.setDate(d.getDate() - 1);
} else {
break;
}
}
return streak;
}
/**
* Renders all tracker cards into #trackerList.
* Each card shows a 28-day hit/partial/miss grid, current streak, and total hit count.
* Attaches delete-button listeners after render.
* @returns {void}
*/
function renderTrackers() {
const el = document.getElementById('trackerList');
if (!el) return;
if (!trackers.length) {
wlLog.info('renderTrackers: empty state');
el.innerHTML = '<div class="plan-empty">No trackers yet — click + New above.</div>';
return;
}
wlLog.info('renderTrackers: rendering trackers', { count: trackers.length });
// Last 28 days (oldest → newest)
const days = Array.from({ length: 28 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - (27 - i));
return dk(d);
});
el.innerHTML = trackers
.map((t) => {
const streak = trackerStreak(t);
const cells = days
.map((dateKey) => {
const status = trackerDayStatus(t, dateKey);
const bg =
status === 'hit' ? t.color : status === 'partial' ? t.color + '55' : 'var(--bg3)';
return `<div class="tr-cell" style="background:${bg}" title="${dateKey}: ${status}"></div>`;
})
.join('');
const hitCount = days.filter((d) => trackerDayStatus(t, d) === 'hit').length;
const targetLabel =
t.targetMinutes >= 60 ? `${t.targetMinutes / 60}h/day` : `${t.targetMinutes}m/day`;
return `
<div class="tracker-card">
<div class="tracker-card-head">
<span class="edot" style="background:${safeCssColor(t.color)}"></span>
<span class="tracker-name">${escHtml(t.name)}</span>
<span class="tracker-target">${targetLabel}</span>
${streak ? `<span class="tracker-streak">🔥 ${streak} day streak</span>` : '<span class="tracker-streak"></span>'}
<button class="tracker-delete" data-id="${escHtml(t.id)}" aria-label="Delete tracker">✕</button>
</div>
<div class="tr-grid">${cells}</div>
<div class="tracker-footer"><span>${hitCount}/28 days hit</span></div>
</div>`;
})
.join('');
document.querySelectorAll('.tracker-delete').forEach((btn) => {
btn.addEventListener('click', () => {
trackers = trackers.filter((t) => t.id !== btn.dataset.id);
saveTrackers();
renderTrackers();
});
});
}
let _trackerFormOpen = false;
/**
* Shows and populates the new-tracker form inside #trackerNewForm.
* Pre-fills the colour picker with the first category's colour.
* Attaches Save / Cancel / keyboard listeners.
* @returns {void}
*/
function openTrackerForm() {
const formEl = document.getElementById('trackerNewForm');
if (!formEl) return;
_trackerFormOpen = true;
formEl.style.display = '';
const defaultColor = categories[0] ? categories[0].color : '#378ADD';
formEl.innerHTML = `
<div class="tr-form">
<div class="tr-form-row">
<label class="tr-form-lbl" for="trFormName">Name</label>
<input class="capture-input tr-form-name" id="trFormName"
placeholder="e.g. Deep work" autocomplete="off" />
</div>
<div class="tr-form-row">
<label class="tr-form-lbl" for="trFormMins">Daily target (minutes)</label>
<input class="capture-input" id="trFormMins" type="number"
min="5" max="480" value="60" style="width:80px" />
</div>
<div class="tr-form-row">
<label class="tr-form-lbl">Categories to count</label>
<div class="tr-form-tags" id="trFormTags">
${categories
.map(
(c) =>
`<label class="tr-tag-check">
<input type="checkbox" value="${escHtml(c.id)}" />
<span class="qp-chip" style="border-color:${safeCssColor(c.color)}44;color:${safeCssColor(c.color)};background:${safeCssColor(c.color)}11">${escHtml(c.label)}</span>
</label>`
)
.join('')}
</div>
</div>
<div class="tr-form-row">
<label class="tr-form-lbl" for="trFormColor">Colour</label>
<input type="color" id="trFormColor" value="${defaultColor}"
style="width:40px;height:28px;cursor:pointer;border:none;background:none;padding:0" />
</div>
<div class="tr-form-actions">
<button class="add-btn" id="trFormCancel">Cancel</button>
<button class="add-btn refl-save" id="trFormSave">Add tracker</button>
</div>
</div>`;
document.getElementById('trFormCancel').addEventListener('click', closeTrackerForm);
document.getElementById('trFormSave').addEventListener('click', saveTrackerForm);
document.getElementById('trFormName').focus();
document.getElementById('trFormName').addEventListener('keydown', (e) => {
if (e.key === 'Enter') saveTrackerForm();
if (e.key === 'Escape') closeTrackerForm();
});
}
/**
* Hides the new-tracker form and resets the open-state flag.
* @returns {void}
*/
function closeTrackerForm() {
const formEl = document.getElementById('trackerNewForm');
if (formEl) formEl.style.display = 'none';
_trackerFormOpen = false;
}
/**
* Reads the new-tracker form, validates inputs, pushes the tracker to the
* `trackers` array, persists it, and re-renders. Shows an alert if no category
* is selected; focuses the name field if the name is empty.
* @returns {void}
*/
function saveTrackerForm() {
const name = (document.getElementById('trFormName')?.value || '').trim();
if (!name) {
wlLog.info('saveTrackerForm: rejected — empty name');
document.getElementById('trFormName')?.focus();
return;
}
const minsRaw = parseInt(document.getElementById('trFormMins')?.value || '60', 10);
const targetMinutes = isNaN(minsRaw) || minsRaw < 1 ? 60 : minsRaw;
const color = document.getElementById('trFormColor')?.value || '#378ADD';
const tags = [...document.querySelectorAll('#trFormTags input[type=checkbox]:checked')].map(
(cb) => cb.value
);
if (!tags.length) {
wlLog.info('saveTrackerForm: rejected — no categories selected', { name });
alert('Please select at least one category.');
return;
}
trackers.push({
id: Date.now() + '',
name,
targetMinutes,
tags,
color,
});
saveTrackers();
wlLog.info('saveTrackerForm: tracker added', { name, targetMinutes, tagCount: tags.length });
closeTrackerForm();
renderTrackers();
}
/**
* Bootstraps the Trackers feature: performs the initial render and wires up
* the "+ New" button to toggle the tracker form open/closed.
* Called once on DOMContentLoaded.
* @returns {void}
*/
function initTrackers() {
renderTrackers();
document.getElementById('trackerAddBtn')?.addEventListener('click', () => {
if (_trackerFormOpen) {
closeTrackerForm();
} else {
openTrackerForm();
}
});
}