/* ── Timeblock — day-boundary carry & completed history ── */
// Split out of 11-timeblock.js (QA finding: module size). Contains the
// plan-task day-boundary lifecycle: carry-over of unfinished tasks
// (autoCarryTasks), post-carry migrations and status patches
// (patchCarriedTasks), iteration expiry dates (seed, load, edit modal, save),
// the completed-task history renderer with its collapse state, and the
// delegated bill-btn / prio-btn document-level click handlers.
//
// Load order: this file must keep its 11 prefix so it concatenates before
// 12a-changelog.js, whose top-level bootstrap calls loadExpiryDates(),
// autoCarryTasks(), patchCarriedTasks() and renderCompleted() — their bodies
// dereference the top-level const/let state declared here.
/**
* Applies data migrations and status patches to today's plan tasks after
* carry-over:
* - Stamps `billable: true` on tasks/categories that predate the feature.
* - Stamps `completedAt` on done tasks missing a timestamp.
* - Promotes today's task status to match the most recent past version when
* that version was pending/blocked/upcoming or in-progress.
*/
function patchCarriedTasks() {
const todayKey = dk(new Date());
const planTasks = getPlanTasks();
const todayTasks = planTasks.filter((task) => task.date === todayKey);
const pastTasks = planTasks.filter((task) => task.date < todayKey);
// Migration: stamp billable on tasks and categories that predate the feature.
// Assumption: the app was originally developed for billable contract work, so
// any task or category without an explicit flag is assumed billable to avoid
// retroactively understating tracked hours.
planTasks.forEach((task) => {
if (task.billable === undefined) task.billable = true;
});
getCategories().forEach((cat) => {
if (cat.billable === undefined) cat.billable = true;
});
// Migration: stamp completedAt on any done task missing it
let changed = false;
planTasks.forEach((task) => {
if (task.status === 'done' && !task.completedAt) {
task.completedAt = new Date((task.date || todayKey) + 'T00:00:00').getTime();
changed = true;
}
});
if (!todayTasks.length || !pastTasks.length) {
if (changed) savePlan();
return;
}
todayTasks.forEach((todayTask) => {
const prev = pastTasks
.filter((task) => task.text.toLowerCase() === todayTask.text.toLowerCase())
.sort((a, b) => b.date.localeCompare(a.date))[0];
if (!prev) return;
const newStatus = resolveCarryStatus(todayTask, prev);
if (newStatus === null) return;
todayTask.status = newStatus;
if (
['pending', 'blocked', 'upcoming'].includes(newStatus) &&
prev.statusComments &&
prev.statusComments.length &&
!todayTask.statusComments
) {
todayTask.statusComments = prev.statusComments.map((comment) => ({ ...comment }));
}
changed = true;
});
if (changed) savePlan();
}
/**
* Carries unfinished plan tasks from past days into today — runs once per day
* (guarded by a localStorage flag). Deduplicates by text, preserving the most
* recent past status and status-comment history. Checkpoints are carried forward
* with `done` reset to false for a fresh day.
* @returns {number|undefined} Number of tasks newly carried, or undefined if
* carry has already run today.
*/
function autoCarryTasks() {
const todayKey = dk(new Date());
const carryKey = 'wl_carried_' + todayKey;
if (localStorage.getItem(carryKey)) return;
const planTasks = getPlanTasks();
// 'upcoming' tasks are intentionally scheduled for a future date by the user
// and should never be auto-carried — they will appear naturally on their target date.
// 'done' tasks are complete and need no carry.
const unfinished = planTasks.filter(
(task) => task.date < todayKey && task.status !== 'done' && task.status !== 'upcoming'
);
// Don't set the guard key when nothing needs carrying — if the user later
// reopens a task that was done at midnight, the next startup can still carry it.
if (!unfinished.length) return;
// Deduplicate by text — keep only the MOST RECENT past version of each task.
// Without this, an older 'inprogress' copy could be carried instead of a newer 'pending' one.
const latestByText = {};
unfinished.forEach((task) => {
const key = task.text.toLowerCase();
// eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
if (!latestByText[key] || task.date > latestByText[key].date) {
// eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
latestByText[key] = task;
}
});
const toCarry = Object.values(latestByText);
// First pass: create new tasks, build old-id → new-id map
const idMap = {};
let carried = 0;
toCarry.forEach((task) => {
const exists = planTasks.some(
(existingTask) =>
existingTask.date === todayKey &&
existingTask.text.toLowerCase() === task.text.toLowerCase()
);
if (!exists) {
const newId = 'c' + Date.now() + Math.random().toString(36).slice(2);
idMap[task.id] = newId;
planTasks.push({
id: newId,
text: task.text,
tag: task.tag,
status: task.status, // preserve inprogress/todo/pending/blocked
...(task.statusComments && task.statusComments.length
? { statusComments: task.statusComments.map((comment) => ({ ...comment })) }
: {}),
// Carry checkpoints forward — reset done state for a fresh day
...(task.checkpoints && task.checkpoints.length
? { checkpoints: task.checkpoints.map((checkpoint) => ({ ...checkpoint, done: false })) }
: {}),
date: todayKey,
});
carried++;
}
});
if (carried > 0) savePlan();
localStorage.setItem(carryKey, '1');
return carried;
}
let completedCollapsed = readCollapseState('completedSection', true);
// ── Iteration expiry dates (stored in localStorage, seeded on first load) ──
const STORE_EXPIRY = 'wl_expiry_dates';
const EXPIRY_SEED = [
// PI 26-1
'2026-01-31',
'2026-02-14',
'2026-02-28',
'2026-03-14',
'2026-03-28',
// PI 26-2
'2026-04-11',
'2026-04-25',
'2026-05-09',
'2026-05-23',
'2026-06-06',
// PI 26-3
'2026-06-20',
'2026-07-04',
'2026-07-18',
'2026-08-01',
'2026-08-15',
'2026-08-29',
// PI 26-4
'2026-09-12',
'2026-09-26',
'2026-10-10',
'2026-10-24',
'2026-11-07',
// PI 26-5
'2026-11-21',
'2026-12-05',
'2026-12-19',
'2027-01-02',
'2027-01-16',
];
let _expiryDates = null; // cached; invalidated when user saves changes
/**
* Loads iteration expiry dates from localStorage into `_expiryDates`.
* Seeds localStorage with `EXPIRY_SEED` on first run.
*/
function loadExpiryDates() {
try {
const raw = localStorage.getItem(STORE_EXPIRY);
if (raw) {
_expiryDates = JSON.parse(raw)
.filter((dateStr) => /^\d{4}-\d{2}-\d{2}$/.test(dateStr))
.sort();
return;
}
} catch (err) {
wlLog.warn('loadExpiryDates: failed to parse stored expiry dates — using defaults', err);
}
// Seed localStorage with defaults on first load
_expiryDates = [...EXPIRY_SEED];
localStorage.setItem(STORE_EXPIRY, JSON.stringify(_expiryDates));
}
/**
* Returns the first iteration expiry date that is strictly later than
* `completedDay`, or null if none is configured beyond that date.
* @param {string} completedDay - Completion date in "YYYY-MM-DD" format.
* @returns {string|null} The next expiry date, or null.
*/
function getIterationExpiry(completedDay) {
if (!_expiryDates) loadExpiryDates();
return _expiryDates.find((dateStr) => dateStr > completedDay) || null;
}
/**
* Opens the iteration-expiry editor modal, pre-filling the textarea with the
* current expiry dates (one per line).
*/
function openExpiryModal() {
if (!_expiryDates) loadExpiryDates();
document.getElementById('expiryTextarea').value = _expiryDates.join('\n');
document.getElementById('expiryFeedback').textContent = '';
document.getElementById('expiryOverlay').classList.add('show');
document.getElementById('expiryTextarea').focus();
}
/**
* Reads the expiry-date textarea, validates each line against YYYY-MM-DD format,
* deduplicates and sorts the valid dates, persists them to localStorage, and closes
* the modal. Invalid lines are surfaced in the feedback element but not saved.
*/
function saveExpiryDates() {
const raw = document.getElementById('expiryTextarea').value;
const dates = raw
.split('\n')
.map((line) => line.trim())
.filter((line) => /^\d{4}-\d{2}-\d{2}$/.test(line));
const invalid = raw
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !/^\d{4}-\d{2}-\d{2}$/.test(line));
if (invalid.length) {
const fb = document.getElementById('expiryFeedback');
fb.style.color = '#f17070';
fb.textContent = `Invalid lines (ignored): ${invalid.join(', ')}`;
}
_expiryDates = [...new Set(dates)].sort();
localStorage.setItem(STORE_EXPIRY, JSON.stringify(_expiryDates));
document.getElementById('expiryOverlay').classList.remove('show');
renderCompleted();
}
/**
* Renders the completed-tasks section for the currently viewed date.
* Shows tasks that were completed on or before the view date and whose
* iteration expiry date has not yet passed. Deduplicates by task text,
* keeping only the most recently completed version. Hides the section when
* there are no matching tasks.
*/
function renderCompleted() {
const viewKey = dk(getViewDate());
// Tasks that are actively inprogress/todo on the current view date
const activeTodayTexts = new Set(
getPlanTasks()
.filter((task) => task.date === viewKey && task.status !== 'done')
.map((task) => task.text.toLowerCase())
);
const done = getPlanTasks()
.filter((task) => {
if (task.status !== 'done') return false;
// Don't show completed tasks that have a live version on this date
if (activeTodayTexts.has(task.text.toLowerCase())) return false;
const completedTs =
task.completedAt || new Date((task.date || viewKey) + 'T23:59:00').getTime();
const completedDay = dk(new Date(completedTs));
const expiryDay = getIterationExpiry(completedDay);
// Show from completion day until (but not including) the iteration expiry date.
// If beyond last known iteration, keep visible indefinitely.
if (!expiryDay) return viewKey >= completedDay;
return viewKey >= completedDay && viewKey < expiryDay;
})
.sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0));
// Deduplicate by text — keep only the most recently completed version of each task
const seen = new Set();
const deduped = done.filter((task) => {
const key = task.text.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const sec = document.getElementById('completedSection');
// Completed history is now shown in the board Done column expander; keep this section hidden.
sec.style.display = 'none';
if (!deduped.length) {
document.getElementById('completedBody').innerHTML = '';
document.getElementById('completedCount').textContent = '0';
return;
}
document.getElementById('completedCount').textContent = `${deduped.length} completed`;
sec.classList.toggle('collapsed', completedCollapsed);
document.getElementById('completedBody').innerHTML = deduped
.map((task) => {
const cat = getCat(task.tag || 'other');
let whenStr = 'date unknown';
if (task.completedAt) {
const d = new Date(task.completedAt);
const mo = d.toLocaleDateString('en', { month: 'long', day: 'numeric', year: 'numeric' });
const hh = d.getHours(),
mm = d.getMinutes();
const isSentinel = (hh === 0 && mm === 0) || (hh === 23 && mm === 59);
whenStr = isSentinel
? `completed ${mo}`
: `completed ${mo} at ${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
} else if (task.date) {
const d = new Date(task.date + 'T12:00:00');
whenStr = `completed ${d.toLocaleDateString('en', { month: 'long', day: 'numeric', year: 'numeric' })}`;
}
return `<div class="completed-item">
<span class="plan-status done-st" style="pointer-events:none;flex-shrink:0;font-size:10px;padding:1px 7px">Done</span>
<span class="completed-dot" style="background:${safeCssColor(cat.color)}"></span>
<span class="completed-text">${task.emoji ? escHtml(task.emoji) + ' ' : ''}${jiraTicketHtml(task.text)}</span>
<span class="completed-when">${whenStr}</span>
</div>`;
})
.join('');
}
document.getElementById('completedHeader').addEventListener('click', () => {
completedCollapsed = !completedCollapsed;
writeCollapseState('completedSection', completedCollapsed);
renderCompleted();
});
// Delegated bill-btn handler — covers plan, pending, completed sections
document.addEventListener(
'click',
(event) => {
const btn = event.target.closest('.bill-btn');
if (!btn) return;
event.stopPropagation();
if (btn.dataset.pid) {
const task = getPlanTasks().find((task) => task.id === btn.dataset.pid);
if (!task) return;
task.billable = task.billable === false ? true : false;
savePlan();
renderPlan();
renderCompleted();
} else if (btn.dataset.etext) {
// Log entry — save billable directly on the entry, and sync to matching planTasks
const entry = getEntries().find((logEntry) => logEntry.id === btn.dataset.eid);
if (!entry) return;
// Determine toggle: if currently billable → make non-billable, and vice versa
const curBill =
entry.billable !== undefined
? entry.billable
: getCat(entry.tag || 'other').billable !== false;
entry.billable = !curBill;
// Also update matching planTasks so plan rows stay in sync
const key = entry.text.toLowerCase().trim();
getPlanTasks()
.filter((task) => task.text.toLowerCase().trim() === key)
.forEach((task) => (task.billable = entry.billable));
save();
savePlan();
render();
}
},
true
);
// Delegated prio-btn handler — cycles priority normal → high → low → normal
document.addEventListener(
'click',
(event) => {
const btn = event.target.closest('.prio-btn');
if (!btn || !btn.dataset.pid) return;
event.stopPropagation();
const task = getPlanTasks().find((task) => task.id === btn.dataset.pid);
if (!task) return;
const cur = task.priority || 0;
const next = cur === 0 ? 1 : cur === 1 ? -1 : 0;
if (next === 0) delete task.priority;
else task.priority = next;
savePlan();
renderPlan();
},
true
);