/* ── Timeblock — grid rendering ── */
// Split out of 11-timeblock.js (QA finding: module size). Contains the full
// renderTimeblock() loop — time labels, slot grid, auto blocks merged from log
// entries, manual planned blocks, untracked-time labels and the now-line —
// plus all grid drag-and-drop wiring and the module-global drag state it
// exclusively owns. positionNowLine() lives here because it is
// render-positioning logic called by renderTimeblock() and 09-clock-weather.js.
//
// Load order: this file must keep its 11 prefix so it concatenates before
// 12a-changelog.js, whose top-level bootstrap calls renderTimeblock() and
// therefore needs the top-level drag state declared here already evaluated.
let tbDragSource = null; // 'grid' | 'plan'
let tbDragId = null; // block id when dragging from grid
/**
* Builds the action buttons shown on a planned block: start (task blocks
* only), add/change emoji, and delete. Each button's accessible name includes
* the block's text because a glyph or "▶ start" alone is announced identically
* on every block, so screen-reader users cannot tell which one they are on
* (WCAG 4.1.2 / 2.4.6).
* @param {{ id: string, text: string, type: (string|undefined), emoji: (string|undefined) }} block - The planned block.
* @returns {string} HTML for the buttons, ready to append inside the block element.
*/
function buildBlockActionButtonsHtml(block) {
const subject = escHtml(block.text);
const startButton =
block.type !== 'meeting'
? `<button class="tb-block-start" data-bid="${block.id}" aria-label="Start tracking: ${subject}" draggable="false">▶ start</button>`
: '';
const emojiVerb = block.emoji ? 'Change' : 'Add';
const emojiButton = `<button class="tb-block-emoji${block.emoji ? ' has-emoji' : ''}" data-bid="${block.id}" aria-label="${emojiVerb} emoji: ${subject}" aria-haspopup="dialog" aria-expanded="false" title="${emojiVerb.toLowerCase()} emoji" draggable="false">${block.emoji ? escHtml(block.emoji) : '✦'}</button>`;
const deleteButton = `<button class="tb-block-del" data-bid="${block.id}" aria-label="Delete block: ${subject}" draggable="false">×</button>`;
return startButton + emojiButton + deleteButton;
}
/**
* Whether a planned meeting block is the one the running timer is tracking.
* Meeting entries are logged with a leading "📅 ", so the entry's text is
* compared both with and without that prefix.
* @param {{ type: (string|undefined), text: string }} block - The planned block.
* @param {{ text: string }|null|undefined} liveEntry - The log entry the timer runs on, if any.
* @returns {boolean} True when `block` is a meeting that matches `liveEntry`.
*/
function isLiveMeetingBlock(block, liveEntry) {
if (!liveEntry || block.type !== 'meeting') return false;
const blockText = block.text.toLowerCase();
const entryText = liveEntry.text.toLowerCase();
return blockText === entryText || blockText === entryText.replace(/^📅\s*/, '');
}
/**
* Accessible name for a planned block. A bare focusable `div` is dropped from
* the accessibility tree unless it has a role, so the block is also given
* `role="group"`; the name carries the time range and says how to move it,
* which screen-reader users cannot otherwise discover.
* @param {{ text: string, slot: number, duration: number }} block - The planned block.
* @returns {string} Text such as "Write report — planned 11:00–12:00, press arrow keys to move".
*/
function blockAriaLabel(block) {
const start = slotToTime(block.slot);
const end = slotToTime(block.slot + block.duration);
return `${block.text} — planned ${start}–${end}, press arrow keys to move`;
}
/**
* Slot change a key press asks for.
* @param {string} key - A `KeyboardEvent.key` value.
* @returns {number} -1 for ArrowUp (earlier), 1 for ArrowDown (later), 0 for any other key.
*/
function blockMoveDelta(key) {
if (key === 'ArrowUp') return -1;
if (key === 'ArrowDown') return 1;
return 0;
}
/**
* Moves keyboard focus to a planned block's element, found by ID after the
* grid has been redrawn. Compares IDs in code instead of building a selector,
* because block IDs come from stored data.
* @param {string} blockId - ID of the block to focus.
* @returns {void}
*/
function focusPlannedBlock(blockId) {
const blockEl = [...document.querySelectorAll('#tbGrid .tb-block.plan')].find(
(candidate) => candidate.dataset.bid === blockId
);
if (blockEl) blockEl.focus();
}
/**
* Keydown handler for a planned block: ArrowUp / ArrowDown move it one
* half-hour slot through moveBlockToSlot(), the same path drag-and-drop uses,
* and focus follows the block across the redraw. Keys pressed on the block's
* own buttons and any Ctrl/Alt/Meta/Shift combination are left alone so those
* controls and assistive-technology shortcuts keep working.
* @param {KeyboardEvent} event - The keydown event, listened for on the block element.
* @param {{ id: string }} block - The block the listener belongs to.
* @param {string} dateKey - YYYY-MM-DD of the day in view.
* @returns {void}
*/
function handleBlockKeydown(event, block, dateKey) {
if (event.target !== event.currentTarget) return;
if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return;
const delta = blockMoveDelta(event.key);
if (!delta) return;
event.preventDefault();
const current = getBlocks().find((candidate) => candidate.id === block.id);
if (!current) return;
if (moveBlockToSlot(block.id, current.slot + delta, dateKey) === 'moved') {
focusPlannedBlock(block.id);
}
}
/**
* Renders the full time-block grid for the currently viewed date: time labels,
* grid rows, planned blocks (with drag-to-move), live timer block, a "now" line,
* and the plan-task drag targets. Also handles drag-and-drop wiring for
* moving existing blocks and dropping tasks from the plan list.
*/
function renderTimeblock() {
const viewDate = getViewDate();
const dateKey = dk(viewDate);
const timer = getActiveTimer();
const entries = getEntries();
const blocks = getBlocks();
const liveEntry = timer ? entries.find((entry) => entry.id === timer.entryId) : null;
// Time labels
const timesEl = document.getElementById('tbTimes');
timesEl.innerHTML = '';
for (let i = 0; i <= TB_SLOTS; i++) {
const d = document.createElement('div');
d.className = 'tb-time-label' + (i === TB_SLOTS ? ' end' : '');
d.textContent = slotToTime(i);
timesEl.appendChild(d);
}
// Build grid slots
const grid = document.getElementById('tbGrid');
grid.innerHTML = '';
for (let i = 0; i < TB_SLOTS; i++) {
const s = document.createElement('div');
s.className = 'tb-slot' + (i % 2 === 1 ? ' half' : '');
s.dataset.slot = i;
grid.appendChild(s);
}
// ── Auto blocks from log entries (render first = below manual blocks) ──
const liveId = timer ? timer.entryId : null;
const tbStart = TB_START * 60,
tbEnd = TB_END * 60;
function minsFromTs(ts) {
const d = new Date(ts);
return d.getHours() * 60 + d.getMinutes();
}
function autoBlockEl(text, tag, startTs, endTs, isLive) {
const cat = getCat(tag || 'other');
const startMins = minsFromTs(startTs);
const endMins = minsFromTs(endTs);
if (startMins >= tbEnd || endMins <= tbStart) return null;
const cStart = Math.max(startMins, tbStart);
const cEnd = Math.min(endMins, tbEnd);
const topPx = ((cStart - tbStart) / 30) * TB_SLOT_H;
const hPx = Math.max(TB_SLOT_H * 0.5, ((cEnd - cStart) / 30) * TB_SLOT_H);
const dur = Math.round((endTs - startTs) / 60000);
const h = Math.floor(dur / 60),
m = dur % 60;
const durStr = h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
const el = document.createElement('div');
el.className = 'tb-block auto' + (isLive ? ' live' : '');
if (isLive) el.id = 'tb-live-block';
el.style.top = topPx + 'px';
el.style.height = hPx + 'px';
el.style.background = cat.color + '28';
el.style.borderLeftColor = cat.color;
el.style.color = cat.color;
const icon = isLive ? '▶ ' : '● ';
el.innerHTML =
`<div class="tb-block-name">${icon}${escHtml(text)}</div>` +
`<div class="tb-block-sub" id="${isLive ? 'tb-live-sub' : ''}">${escHtml(cat.label)} · ${durStr}</div>`;
return el;
}
const meetingNames = new Set(
getBlocks()
.filter((block) => block.date === dateKey && block.type === 'meeting')
.map((block) => block.text.toLowerCase())
);
// Merge same-task entries with < 30 min gap into a single visual block
function mergeAutoEntries(rawEntries) {
const sorted = [...rawEntries].sort((a, b) => a.ts - b.ts);
const merged = [];
for (const e of sorted) {
const prev = merged[merged.length - 1];
const prevEnd = prev ? prev._mergedEnd || prev.ts : 0;
if (
prev &&
prev.text.toLowerCase() === e.text.toLowerCase() &&
e.ts - prevEnd <= 30 * 60 * 1000
) {
prev._mergedEnd = Math.max(prevEnd, e.tsEnd || e.ts);
prev.tag = prev.tag || e.tag;
} else {
merged.push({ ...e, _mergedEnd: e.tsEnd || e.ts });
}
}
return merged;
}
const dayAutoEntries = entries.filter(
(entry) =>
entry.date === dateKey &&
entry.id !== liveId &&
!meetingNames.has(entry.text.replace(/^📅\s*/, '').toLowerCase()) &&
!meetingNames.has(entry.text.toLowerCase()) &&
(entry.tsEnd || isToday(viewDate))
);
mergeAutoEntries(dayAutoEntries).forEach((entry) => {
const endTs = entry._mergedEnd || (isToday(viewDate) ? Date.now() : null);
if (!endTs) return;
const el = autoBlockEl(entry.text, entry.tag, entry.ts, endTs, false);
if (el) grid.appendChild(el);
});
// Live timer block — skip if the active timer is a meeting block (it will pulse instead)
if (liveId) {
const le = entries.find((entry) => entry.id === liveId);
const isMeetingBlock =
le &&
blocks.some(
(block) =>
block.date === dateKey &&
block.type === 'meeting' &&
block.text.toLowerCase() === le.text.toLowerCase()
);
if (le && le.date === dateKey && !isMeetingBlock) {
const fakeEnd = timer.paused
? le.ts + (timer.accumulatedMs || 0) // paused: stop at pause point
: Math.max(Date.now(), le.ts + 60000); // running: extend to now
const el = autoBlockEl(le.text, le.tag, le.ts, fakeEnd, true);
if (el) grid.appendChild(el);
}
}
// ── Manual planned blocks (render last = on top, dashed border) ──
const dayBlocks = blocks.filter((block) => block.date === dateKey);
const tbLiveEntry = liveEntry;
dayBlocks.forEach((block) => {
const cat = getCat(block.tag || 'other');
const el = document.createElement('div');
const isDone = getPlanTasks().some(
(task) =>
task.date === dateKey &&
task.text.toLowerCase() === block.text.toLowerCase() &&
task.status === 'done'
);
const isMeetingBlock = isLiveMeetingBlock(block, tbLiveEntry);
el.className = 'tb-block plan' + (isDone ? ' task-done' : '') + (isMeetingBlock ? ' live' : '');
el.dataset.bid = block.id;
el.draggable = true;
el.tabIndex = 0;
el.setAttribute('role', 'group');
el.setAttribute('aria-label', blockAriaLabel(block));
el.style.top = block.slot * TB_SLOT_H + 1 + 'px';
el.style.height = block.duration * TB_SLOT_H - 3 + 'px';
el.style.background = cat.color + '18';
el.style.borderLeftColor = cat.color;
el.style.color = cat.color;
const icon = block.type === 'meeting' ? '📅 ' : '';
const emojiPrefix = block.emoji ? escHtml(block.emoji) + ' ' : '';
const dur = block.duration * 30;
const h = Math.floor(dur / 60),
m = dur % 60;
const durStr = h > 0 ? (m > 0 ? `${h}h ${m}min` : `${h}h`) : `${m}min`;
el.innerHTML =
`<div class="tb-block-name">${emojiPrefix}${icon}${escHtml(block.text)}</div>` +
(block.duration > 1
? `<div class="tb-block-sub">${escHtml(cat.label)} · ${durStr}</div>`
: '') +
buildBlockActionButtonsHtml(block);
el.addEventListener('dragstart', (event) => {
tbDragSource = 'grid';
tbDragId = block.id;
event.dataTransfer.effectAllowed = 'move';
});
el.addEventListener('dragend', () => {
tbDragSource = null;
tbDragId = null;
});
el.addEventListener('keydown', (event) => handleBlockKeydown(event, block, dateKey));
el.querySelector('.tb-block-del').addEventListener('click', (event) => {
event.stopPropagation();
removeBlockById(block.id);
});
const startBtn = el.querySelector('.tb-block-start');
if (startBtn)
startBtn.addEventListener('click', (event) => {
event.stopPropagation();
tbStartBlock(block.id);
});
el.querySelector('.tb-block-emoji').addEventListener('click', (event) => {
event.stopPropagation();
openBlockEmojiPicker(block.id, event.currentTarget);
});
grid.appendChild(el);
});
// Untracked time — show faint label on past slots with no coverage (any viewed date)
const nowMins = isToday(viewDate)
? new Date().getHours() * 60 + new Date().getMinutes()
: TB_END * 60; // for past days, all slots are "past"
// Use start-of-day as floor — slots before work started aren't "untracked"
const sodTs = isToday(viewDate) ? getDayStart() : null;
const sodMins = sodTs
? new Date(sodTs).getHours() * 60 + new Date(sodTs).getMinutes()
: TB_START * 60; // no start set — use grid start as default
// Build a set of 30-min slots that have coverage (from entries or planned blocks)
const coveredSlots = new Set();
entries
.filter((entry) => entry.date === dateKey && entry.tsEnd)
.forEach((entry) => {
const startSlot = timeToSlot(new Date(entry.ts).getHours(), new Date(entry.ts).getMinutes());
// If tsEnd is exactly on a 30-min boundary (e.g. 09:30:00), back off 1 minute
// so we don't accidentally mark the NEXT slot as covered
const endD = new Date(entry.tsEnd);
const onBoundary = endD.getMinutes() % 30 === 0 && endD.getSeconds() === 0;
// timeToSlot uses Math.round(m/30), so backing off 1 min (→29) still rounds to slot+1.
// Instead compute endSlot directly: if on a boundary, the entry ends AT that boundary,
// meaning the boundary's slot is NOT covered — use the slot before it.
const endSlot = onBoundary
? timeToSlot(endD.getHours(), endD.getMinutes()) - 1
: timeToSlot(endD.getHours(), endD.getMinutes());
for (let s = Math.max(0, startSlot); s < Math.min(TB_SLOTS, endSlot + 1); s++)
coveredSlots.add(s);
});
if (timer && liveEntry && liveEntry.date === dateKey) {
const startSlot = timeToSlot(
new Date(liveEntry.ts).getHours(),
new Date(liveEntry.ts).getMinutes()
);
if (timer.paused) {
// Paused: only cover slots up to the pause point
const pauseEnd = new Date(liveEntry.ts + (timer.accumulatedMs || 0));
const endSlot = timeToSlot(pauseEnd.getHours(), pauseEnd.getMinutes());
for (let s = Math.max(0, startSlot); s < Math.min(TB_SLOTS, endSlot + 1); s++)
coveredSlots.add(s);
} else {
for (let s = Math.max(0, startSlot); s < TB_SLOTS; s++) coveredSlots.add(s);
}
}
blocks
.filter((block) => block.date === dateKey)
.forEach((block) => {
for (let s = block.slot; s < Math.min(TB_SLOTS, block.slot + block.duration); s++)
coveredSlots.add(s);
});
for (let slot = 0; slot < TB_SLOTS; slot++) {
const slotStartMins = TB_START * 60 + slot * 30;
if (slotStartMins < sodMins) continue; // before work started — not untracked
const isPast = slotStartMins < nowMins; // slot has started (not necessarily fully elapsed)
if (!isPast || coveredSlots.has(slot)) continue;
const untracked = document.createElement('div');
untracked.className = 'tb-untracked';
untracked.style.top = slot * TB_SLOT_H + 1 + 'px';
untracked.style.height = TB_SLOT_H - 2 + 'px';
untracked.textContent = 'untracked';
grid.appendChild(untracked);
}
// Current time indicator (today only)
if (isToday(viewDate)) {
const nowLine = document.createElement('div');
nowLine.className = 'tb-now-line';
nowLine.id = 'tbNowLine';
grid.appendChild(nowLine);
positionNowLine();
}
// Grid-level drag/drop (works even when blocks overlap slots)
grid._dragSlot = 0;
grid.addEventListener('dragover', (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const rect = grid.getBoundingClientRect();
const slot = Math.max(
0,
Math.min(TB_SLOTS - 1, Math.floor((event.clientY - rect.top) / TB_SLOT_H))
);
grid
.querySelectorAll('.tb-slot.drag-over')
.forEach((slotEl) => slotEl.classList.remove('drag-over'));
const slotEl = grid.querySelector(`[data-slot="${slot}"]`);
if (slotEl) slotEl.classList.add('drag-over');
grid._dragSlot = slot;
});
grid.addEventListener('dragleave', (event) => {
if (!grid.contains(event.relatedTarget))
grid
.querySelectorAll('.tb-slot.drag-over')
.forEach((slotEl) => slotEl.classList.remove('drag-over'));
});
grid.addEventListener('drop', (event) => {
event.preventDefault();
grid
.querySelectorAll('.tb-slot.drag-over')
.forEach((slotEl) => slotEl.classList.remove('drag-over'));
const target = grid._dragSlot;
if (tbDragSource === 'grid' && tbDragId) moveBlockToSlot(tbDragId, target, dateKey);
tbDragSource = null;
tbDragId = null;
});
}
/**
* Positions the "now" indicator line in the time-block grid to reflect the
* current time. Hides the line when outside the grid's time range
* (`TB_START`–`TB_END`).
*/
function positionNowLine() {
const el = document.getElementById('tbNowLine');
if (!el) return;
const now = new Date();
const mins = now.getHours() * 60 + now.getMinutes();
const tbStartMins = TB_START * 60,
tbEndMins = TB_END * 60;
if (mins < tbStartMins || mins > tbEndMins) {
el.style.display = 'none';
return;
}
el.style.display = 'block';
el.style.top = ((mins - tbStartMins) / 30) * TB_SLOT_H + 'px';
}