// ── 08a-pomo-dashboard.js — Pomodoro 4-column card: sparkline + ribbon ──
//
// This module runs after 08-pomodoro.js in the concatenated build.
// It reads `pomoGetLog()` and the shared `entries`/`activeTimer` globals
// defined in earlier modules. All DOM interactions are guarded by
// `getElementById` null-checks so the module is inert when elements are
// absent (e.g. during unit tests that mount a partial DOM).
const POMO_SPARKLINE_DAYS = 28;
const POMO_RIBBON_DOT_COUNT = 5;
/**
* @typedef {Object} PomoSessionEntry
* @property {number} ts - Unix timestamp ms when the session was logged.
* @property {number} mins - Duration of the session in minutes.
* @property {string|null} task - Task name linked to the session, or null if none.
*/
/**
* Returns the number of completed pomodoro sessions on the given date.
* @param {PomoSessionEntry[]} log - Session log.
* @param {string} dateKey - Date in YYYY-MM-DD format.
* @returns {number}
*/
function pomoSessionsOnDate(log, dateKey) {
return log.filter((e) => dk(new Date(e.ts)) === dateKey).length;
}
/**
* Builds an array of POMO_SPARKLINE_DAYS date strings ending today,
* oldest first.
* @returns {string[]} Array of YYYY-MM-DD strings.
*/
function pomoBuildDateRange() {
return Array.from({ length: POMO_SPARKLINE_DAYS }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - (POMO_SPARKLINE_DAYS - 1 - i));
return dk(d);
});
}
/**
* Reads the --pomo-spark-fill and --pomo-spark-empty CSS variables for the
* active colour scheme. Falls back to hardcoded values when CSS vars are
* unavailable (e.g. headless test environments).
* @returns {{ fill: string, empty: string }}
*/
function pomoSparkColours() {
const style = getComputedStyle(document.documentElement);
return {
fill: style.getPropertyValue('--pomo-spark-fill').trim() || '#c62828',
empty: style.getPropertyValue('--pomo-spark-empty').trim() || '#e8edf4',
};
}
/**
* Draws a 28-day focus density bar chart on the `#pomoSparkline` canvas.
* Each bar represents one calendar day; bar height is proportional to the
* number of completed pomodoro sessions on that day.
* No-op when the canvas element is absent or the 2D context is unavailable.
*/
function renderPomoSparkline() {
const canvas = document.getElementById('pomoSparkline');
if (!canvas || !canvas.getContext) return;
const log = pomoGetLog();
const days = pomoBuildDateRange();
const counts = days.map((d) => pomoSessionsOnDate(log, d));
const maxCount = Math.max(...counts, 1); // guard against all-zero
const dpr = window.devicePixelRatio || 1;
// Read live layout width so the drawing stays sharp if the column ever changes.
// Fall back to 170 in headless environments where getBoundingClientRect returns 0.
const cssW = canvas.getBoundingClientRect().width || 170;
const cssH = 72; // matches .pomo-sparkline-canvas { height: 72px } in _pomo.scss
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cssW, cssH);
const { fill, empty } = pomoSparkColours();
const barUnit = cssW / POMO_SPARKLINE_DAYS;
// 72 % bar width, 28 % gap between bars
const barW = barUnit * 0.72;
const maxBarH = cssH - 6; // 6 px bottom margin for breathing room
counts.forEach((count, i) => {
const x = i * barUnit;
const barH = count > 0 ? Math.max(3, (count / maxCount) * maxBarH) : 2;
const y = cssH - barH - 3;
ctx.fillStyle = count > 0 ? fill : empty;
// roundRect is baseline-available since March 2023; fall back to fillRect
if (typeof ctx.roundRect === 'function') {
ctx.beginPath();
ctx.roundRect(x, y, barW, barH, 2);
ctx.fill();
} else {
ctx.fillRect(x, y, barW, barH);
}
});
}
/**
* Renders the ribbon footer below the 4-column grid:
* - `#pomoRibbonDots`: last-5-session dot sequence (● filled, · empty slot)
* - `#pomoRibbonPill`: "Peak Focus" pill when today matches the all-time high,
* otherwise "N sessions today", hidden when no sessions today
* - `#pomoRibbonLink`: "View all N sessions ↓" button that scrolls `#pomoLog`
*
* Each element is individually guarded — no-op when absent from the DOM.
*/
function renderPomoRibbon() {
const log = pomoGetLog();
const dotsEl = document.getElementById('pomoRibbonDots');
const pillEl = document.getElementById('pomoRibbonPill');
const linkEl = document.getElementById('pomoRibbonLink');
if (dotsEl) {
const slice = log.slice(0, POMO_RIBBON_DOT_COUNT);
dotsEl.innerHTML = Array.from({ length: POMO_RIBBON_DOT_COUNT }, (_, i) => {
if (i < slice.length) {
const session = slice[i];
const taskPart = session.task ? ` — ${escHtml(session.task)}` : '';
const label = `${session.mins} min session${taskPart}`;
return `<span class="pomo-rdot pomo-rdot-filled" title="${label}" aria-hidden="true">●</span>`;
}
return `<span class="pomo-rdot pomo-rdot-empty" aria-hidden="true">·</span>`;
}).join('');
}
if (pillEl) {
const today = dk(new Date());
// Tally sessions per calendar day across the full log
const perDay = {};
log.forEach((e) => {
const d = dk(new Date(e.ts));
perDay[d] = (perDay[d] || 0) + 1;
});
const todayCount = perDay[today] || 0;
const peakCount = Object.values(perDay).reduce((a, b) => Math.max(a, b), 0);
if (todayCount > 0 && todayCount >= peakCount) {
pillEl.textContent = `🔥 Peak Focus — ${todayCount} session${todayCount > 1 ? 's' : ''}`;
} else if (todayCount > 0) {
pillEl.textContent = `${todayCount} session${todayCount > 1 ? 's' : ''} today`;
} else {
pillEl.textContent = '';
}
}
if (linkEl) {
const total = log.length;
linkEl.textContent = total > 0 ? `View all ${total} sessions ↓` : 'No sessions yet';
linkEl.onclick = () => {
document.getElementById('pomoLog')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
};
}
}
/**
* Updates `#pomoTaskLabel` (composer column) with the text of the currently
* running timer entry. Clears the label when no timer is active.
*/
function updatePomoTaskLabel() {
const el = document.getElementById('pomoTaskLabel');
if (!el) return;
const liveEntry = activeTimer ? entries.find((e) => e.id === activeTimer.entryId) : null;
el.textContent = liveEntry ? liveEntry.text : '';
}
/**
* Full Pomodoro dashboard refresh: redraws the 28-day sparkline, ribbon
* footer, and composer task label.
* Called on module load and after every session completion.
*/
function refreshPomoDashboard() {
renderPomoSparkline();
renderPomoRibbon();
updatePomoTaskLabel();
}
// Run once on initial load — all earlier modules are concatenated by now
refreshPomoDashboard();