// ── 11-timeflow.js — Today's Flow unified section (Flow / Log / Blocks views) ──
/** Activity-line SVG icon for the section header (Lucide-style, 24×24 viewBox, 1.5px stroke). */
const ICON_ACTIVITY =
'<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24"' +
' fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';
const STORE_FLOW_VIEW = 'wl_flow_view';
/** 07:00 in minutes from midnight — left edge of the day-overview strip. */
const TF_STRIP_START = 7 * 60;
/** 21:00 in minutes from midnight — right edge of the day-overview strip. */
const TF_STRIP_END = 21 * 60;
/** Ordered list of view ids — drives the segmented control and keyboard nav. */
const TF_VIEWS = ['flow', 'log', 'blocks', 'month'];
/** Display labels for the segmented control. Static so we avoid recomputing on every render. */
const TF_VIEW_LABELS = { flow: 'Flow', log: 'Log', blocks: 'Blocks', month: 'Month' };
/** Maps each view to the DOM id of the pane that hosts it. */
const TF_PANE_IDS = {
flow: 'tfFlowPane',
log: 'tfLogPane',
blocks: 'tfBlocksPane',
month: 'monthlyLogSection',
};
// ─────────────────────────── view preference ───────────────────────────
/**
* Returns the persisted view preference, defaulting to 'flow'.
* @returns {'flow'|'log'|'blocks'|'month'}
*/
function getFlowView() {
const stored = localStorage.getItem(STORE_FLOW_VIEW);
return stored === 'log' || stored === 'blocks' || stored === 'month' ? stored : 'flow';
}
/**
* Persists the active view selection.
* @param {'flow'|'log'|'blocks'} view
*/
function setFlowView(view) {
localStorage.setItem(STORE_FLOW_VIEW, view);
}
// ─────────────────────────── day-overview strip ───────────────────────────
/**
* Converts a minutes-from-midnight value to a percentage across the strip range.
* Clamped to [0, 100].
* @param {number} mins
* @returns {number}
*/
function stripPct(mins) {
return Math.max(
0,
Math.min(100, ((mins - TF_STRIP_START) / (TF_STRIP_END - TF_STRIP_START)) * 100)
);
}
/**
* Converts a Unix timestamp (ms) to minutes-from-midnight in local time.
* @param {number} ts
* @returns {number}
*/
function tsToMins(ts) {
const date = new Date(ts);
return date.getHours() * 60 + date.getMinutes();
}
/**
* Formats a Unix timestamp (ms) as `HH:MM` in local time.
* @param {number} ts
* @returns {string}
*/
function fmtHm(ts) {
const date = new Date(ts);
const hh = String(date.getHours()).padStart(2, '0');
const mm = String(date.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
/**
* Returns the effective tracked duration (ms) of an active-timer entry,
* honouring pause state — matches the formula used by renderFlowHeader so
* the strip bar and Flow-view duration freeze while paused.
* @param {object} entry
* @returns {number} 0 if no timer is active for this entry.
*/
function activeTimerDurationMs(entry) {
if (!activeTimer || activeTimer.entryId !== entry.id) return 0;
if (activeTimer.paused) return activeTimer.accumulatedMs || 0;
return Math.max(0, Date.now() - (activeTimer.startTs || entry.ts));
}
/**
* Renders the compact day-overview strip: hour-tick labels, entry footprints,
* and (today only) a live "now" cursor.
* @param {string} dateKey - YYYY-MM-DD.
*/
function renderDayStrip(dateKey) {
const el = document.getElementById('tfDayStrip');
if (!el) return;
// Capture wall-clock time once so the live bar and now-cursor stay consistent
const nowMins = tsToMins(Date.now());
// Hour-tick labels at two-hour intervals
const ticks = [7, 9, 11, 13, 15, 17, 19, 21]
.map(
(h) =>
`<span class="tf-tick" style="left:${stripPct(h * 60)}%">${String(h).padStart(2, '0')}</span>`
)
.join('');
// Completed entry footprints — skip entries entirely outside the strip
// (right === left after clamping) to avoid phantom slivers at the edges.
const bars = entries
.filter((e) => e.date === dateKey && e.tsEnd && e.signifier !== 'cancelled')
.map((e) => {
const cat = getCat(e.tag);
const left = stripPct(Math.max(TF_STRIP_START, tsToMins(e.ts)));
const right = stripPct(Math.min(TF_STRIP_END, tsToMins(e.tsEnd)));
if (right <= left) return '';
return `<div class="tf-bar" style="left:${left}%;width:${Math.max(0.5, right - left)}%;background:${safeCssColor(cat.color)}"></div>`;
})
.join('');
// Live timer footprint — endpoint freezes at pause so the bar doesn't keep
// growing while the user is paused (matches renderFlowHeader's totals math).
let liveBar = '';
if (activeTimer && isToday(viewDate)) {
const liveEntry = entries.find((e) => e.id === activeTimer.entryId);
if (liveEntry && liveEntry.date === dateKey) {
const liveEndMins = tsToMins(liveEntry.ts + activeTimerDurationMs(liveEntry));
const left = stripPct(Math.max(TF_STRIP_START, tsToMins(liveEntry.ts)));
const right = stripPct(Math.min(TF_STRIP_END, liveEndMins));
if (right > left) {
const cat = getCat(liveEntry.tag);
liveBar = `<div class="tf-bar tf-bar-live" style="left:${left}%;width:${right - left}%;background:${safeCssColor(cat.color)}"></div>`;
}
}
}
// Now cursor (today only)
let nowCursor = '';
if (isToday(viewDate) && nowMins >= TF_STRIP_START && nowMins <= TF_STRIP_END) {
nowCursor = `<div class="tf-now-cursor" style="left:${stripPct(nowMins)}%"></div>`;
}
el.innerHTML = `<div class="tf-strip-ticks">${ticks}</div><div class="tf-strip-bar">${bars}${liveBar}${nowCursor}</div>`;
}
// ─────────────────────────── gap reminder ───────────────────────────
/**
* Finds the largest untracked gap (≥ 15 min) today, including gaps between
* consecutive completed entries AND the trailing gap from the most recent
* entry's end to now (which is often the most actionable). Returns null for
* past days — only actionable today.
* @param {string} dateKey - YYYY-MM-DD.
* @returns {{startTs: number, endTs: number, gapMin: number}|null}
*/
function findLargestGap(dateKey) {
if (!isToday(viewDate)) return null;
const timed = entries
.filter((e) => e.date === dateKey && e.tsEnd && e.signifier !== 'cancelled')
.sort((a, b) => a.ts - b.ts);
let largest = null;
// Internal gaps between consecutive completed entries
for (let i = 0; i < timed.length - 1; i++) {
const gapMin = Math.floor((timed[i + 1].ts - timed[i].tsEnd) / 60000);
if (gapMin >= 15 && (!largest || gapMin > largest.gapMin)) {
largest = { startTs: timed[i].tsEnd, endTs: timed[i + 1].ts, gapMin };
}
}
// Trailing gap: last entry's end → now (or EOD if the day has been ended).
// Suppressed while a live timer is running, since the user is actively
// tracking and the gap will close itself.
if (timed.length && !activeTimer) {
const last = timed[timed.length - 1];
const eodTs = getEodTs();
const ceiling = eodTs || Date.now();
const trailingMin = Math.floor((ceiling - last.tsEnd) / 60000);
if (trailingMin >= 15 && (!largest || trailingMin > largest.gapMin)) {
largest = { startTs: last.tsEnd, endTs: ceiling, gapMin: trailingMin };
}
}
return largest;
}
/**
* Renders or hides the gap-reminder banner.
* @param {string} dateKey
*/
function renderGapReminder(dateKey) {
const el = document.getElementById('tfGapReminder');
if (!el) return;
const gap = findLargestGap(dateKey);
if (!gap) {
el.style.display = 'none';
return;
}
const hours = Math.floor(gap.gapMin / 60);
const mins = gap.gapMin % 60;
let dur;
if (hours > 0) dur = mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
else dur = `${mins}m`;
el.style.display = '';
el.innerHTML = `<span class="tf-gap-text">${fmtHm(gap.startTs)} – ${fmtHm(gap.endTs)} · ${dur} untracked between blocks</span><button type="button" class="tf-gap-btn" id="tfGapLogBtn">+ log it</button>`;
}
// ─────────────────────────── section header ───────────────────────────
/**
* Renders the section header: icon, title, tracked/billable totals, and
* the Flow / Log / Blocks segmented control.
* @param {string} dateKey
* @param {'flow'|'log'|'blocks'} activeView
*/
function renderFlowHeader(dateKey, activeView) {
const el = document.getElementById('tfHeader');
if (!el) return;
const dayEntries = entries.filter(
(e) => e.date === dateKey && e.tsEnd && e.signifier !== 'cancelled'
);
let totalMs = dayEntries.reduce((sum, e) => sum + (e.tsEnd - e.ts), 0);
let billMs = dayEntries
.filter((e) => isEntryBillable(e))
.reduce((sum, e) => sum + (e.tsEnd - e.ts), 0);
// Include live timer duration so both totals update while tracking
if (activeTimer && isToday(viewDate)) {
const liveEntry = entries.find((e) => e.id === activeTimer.entryId);
if (liveEntry && liveEntry.date === dateKey && !liveEntry.tsEnd) {
const liveMs = activeTimerDurationMs(liveEntry);
totalMs += liveMs;
if (isEntryBillable(liveEntry)) billMs += liveMs;
}
}
const totalsHtml =
totalMs > 0
? `<span class="tf-totals">${fmtDur(totalMs)} tracked${billMs > 0 ? ` · <span class="tf-bill">${fmtDur(billMs)} billable</span>` : ''}</span>`
: '';
// Segmented control uses ARIA `tablist`/`tab` so screen readers announce the
// mutually-exclusive selection correctly and link each tab to its pane.
// Roving tabindex: only the active tab is in the tab order; arrows move within.
const segHtml = TF_VIEWS.map((view) => {
const isActive = view === activeView;
return `<button type="button" role="tab" class="tf-seg-btn${isActive ? ' active' : ''}" data-view="${view}" id="tfTab-${view}" aria-selected="${isActive}" aria-controls="${TF_PANE_IDS[view]}" tabindex="${isActive ? '0' : '-1'}">${TF_VIEW_LABELS[view]}</button>`;
}).join('');
el.innerHTML = `<span class="section-icon tf-icon" aria-hidden="true">${ICON_ACTIVITY}</span><span class="tf-title">TODAY'S FLOW</span>${totalsHtml}<div class="tf-seg" id="tfSeg" role="tablist" aria-label="Select view">${segHtml}</div>`;
}
// ─────────────────────────── Flow view ───────────────────────────
/**
* Partitions a flat item list from buildDailyLogItems into two structures:
* non-session-note items (the main timeline rows) and a lookup of session-note
* items keyed by their `parentEntryId`. Session-notes render nested inside
* their parent entry row rather than as standalone timeline entries.
*
* @param {Array<object>} allItems - Items returned by buildDailyLogItems.
* @returns {{ items: Array<object>, sessionNotesByEntry: Record<string, Array<object>> }}
*/
function partitionSessionNotes(allItems) {
const sessionNotesByEntry = {};
const items = allItems.filter((item) => {
if (item.type !== 'session-note') return true;
const pid = item.parentEntryId;
if (!pid) {
wlLog.warn('partitionSessionNotes: orphaned session-note discarded, id=' + item.id);
return false;
}
if (!sessionNotesByEntry[pid]) sessionNotesByEntry[pid] = [];
sessionNotesByEntry[pid].push(item);
return false;
});
return { items, sessionNotesByEntry };
}
/**
* Builds the HTML fragment for a list of session-notes nested under a parent
* entry row. Returns an empty string when there are no notes.
* @param {Array<object>} notes - Session-note items for one parent entry.
* @returns {string} HTML string, or `''` if notes is empty.
*/
function buildSessionNotesHtml(notes) {
if (!notes.length) return '';
return (
`<ul class="tf-session-notes" aria-label="Session notes">` +
notes
.map(
(n) =>
`<li class="tf-session-note">` +
`<span class="tf-sn-time">${fmtHm(n.ts)}</span>` +
`<span class="tf-sn-text">${n.text}</span>` +
`</li>`
)
.join('') +
`</ul>`
);
}
/**
* Renders the Flow view: a vertical list where each entry's accent strip height
* is proportional to its duration (height = max(64, 0.6 × minutes) px), giving
* longer tasks more visual weight.
* @param {string} dateKey
*/
function renderFlowView(dateKey) {
const el = document.getElementById('tfFlowPane');
if (!el) return;
const { items, sessionNotesByEntry } = partitionSessionNotes(buildDailyLogItems(dateKey));
if (!items.length) {
el.innerHTML = `<div class="tf-empty">No entries for ${isToday(viewDate) ? 'today' : 'this day'} yet.</div>`;
return;
}
el.innerHTML = items
.map((item) => {
const startLabel = fmtHm(item.ts);
// Look up the underlying entry object for entry-type items
const entryObj =
item.type === 'entry' && item.entryId ? entries.find((e) => e.id === item.entryId) : null;
let durationMin = 0;
let durMs = 0;
let isLive = false;
if (entryObj) {
isLive = !!(activeTimer && activeTimer.entryId === entryObj.id);
// Use the paused-aware helper for live entries so the duration freezes
// while the timer is paused, matching renderFlowHeader and renderDayStrip.
const liveMs = isLive ? activeTimerDurationMs(entryObj) : 0;
durMs = entryObj.tsEnd ? entryObj.tsEnd - entryObj.ts : liveMs;
if (durMs > 0) durationMin = Math.max(1, Math.round(durMs / 60000));
}
// Strip height scales with duration; non-entry items (notes, tasks) get a fixed height
const stripH = item.type === 'entry' ? Math.max(64, Math.round(0.6 * durationMin)) : 40;
const notes = entryObj ? sessionNotesByEntry[entryObj.id] || [] : [];
return `
<div class="tf-flow-row${isLive ? ' live' : ''}">
<div class="tf-flow-time">
<span class="tf-flow-hm">${startLabel}</span>
${durMs > 0 ? `<span class="tf-flow-dur">${fmtDur(durMs)}</span>` : ''}
</div>
<div class="tf-flow-strip" style="height:${stripH}px;background:${safeCssColor(item.color)}">
${isLive ? '<span class="tf-flow-pulse" aria-hidden="true"></span>' : ''}
</div>
<div class="tf-flow-body" style="min-height:${stripH}px">
<div class="tf-flow-text">${item.text}</div>
<div class="tf-flow-sub">${item.sub}</div>
${buildSessionNotesHtml(notes)}
</div>
</div>`;
})
.join('');
}
// ─────────────────────────── main render ───────────────────────────
/**
* Renders the Today's Flow section: header, day-overview strip, gap reminder,
* and the active view pane (Flow / Log / Blocks / Month).
* Called from render() on every state change and when the view toggle fires.
*/
function renderTodayFlow() {
const dateKey = dk(viewDate);
const activeView = getFlowView();
renderFlowHeader(dateKey, activeView);
// Day strip and gap reminder are hidden in Month view
const stripWrap = document.querySelector('.tf-day-strip-wrap');
const gapEl = document.getElementById('tfGapReminder');
const hideStripAndGap = activeView === 'month';
if (stripWrap) stripWrap.style.display = hideStripAndGap ? 'none' : '';
if (!hideStripAndGap) {
renderDayStrip(dateKey);
renderGapReminder(dateKey);
} else if (gapEl) {
gapEl.style.display = 'none';
}
Object.entries(TF_PANE_IDS).forEach(([view, id]) => {
const pane = document.getElementById(id);
if (pane) pane.style.display = view === activeView ? '' : 'none';
});
if (activeView === 'flow') renderFlowView(dateKey);
else if (activeView === 'log') {
const noteRow = document.getElementById('dailyLogNoteRow');
if (noteRow) noteRow.style.display = isToday(viewDate) ? '' : 'none';
} else if (activeView === 'blocks') renderTimeblock();
else if (activeView === 'month') renderMonthlyLog();
}
/**
* Selects the view at index `nextIndex` in TF_VIEWS, focuses its tab button,
* and re-renders. Shared by the keyboard handler in initTodayFlow().
* @param {number} nextIndex
*/
function focusTabAt(nextIndex) {
const view = TF_VIEWS[nextIndex];
setFlowView(view);
renderTodayFlow();
document.getElementById(`tfTab-${view}`)?.focus();
}
/**
* Binds the static listeners exactly once on DOMContentLoaded:
* - Log-note input/button (static HTML, never recreated)
* - Segmented-control click + keyboard nav, delegated off the stable
* #tfHeader container so we survive its innerHTML being rewritten
* by every renderFlowHeader() call.
* Avoids the listener-accumulation bug that occurred when binding happened
* inside render functions.
*/
function initTodayFlow() {
document.getElementById('dailyLogNoteBtn')?.addEventListener('click', addLogNote);
document.getElementById('dailyLogNoteInput')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') addLogNote();
});
// Gap-reminder "+ log it" button: delegated from the stable #tfGapReminder
// container so renderGapReminder() stays single-purpose (markup only).
document.getElementById('tfGapReminder')?.addEventListener('click', (e) => {
if (!e.target.closest('#tfGapLogBtn')) return;
document.getElementById('captureInput')?.focus();
});
const header = document.getElementById('tfHeader');
if (!header) return;
header.addEventListener('click', (e) => {
const btn = e.target.closest('.tf-seg-btn');
if (!btn) return;
setFlowView(btn.dataset.view);
// Sync month calendar to viewDate when entering Month tab
if (btn.dataset.view === 'month') {
_mlYear = viewDate.getFullYear();
_mlMonth = viewDate.getMonth();
}
renderTodayFlow();
});
// WCAG 2.1.1: Arrow keys + Home/End navigate between tabs in the tablist.
header.addEventListener('keydown', (e) => {
if (!e.target.classList || !e.target.classList.contains('tf-seg-btn')) return;
const current = TF_VIEWS.indexOf(getFlowView());
let next;
if (e.key === 'ArrowLeft') next = (current - 1 + TF_VIEWS.length) % TF_VIEWS.length;
else if (e.key === 'ArrowRight') next = (current + 1) % TF_VIEWS.length;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = TF_VIEWS.length - 1;
else return;
e.preventDefault();
focusTabAt(next);
});
}