From 170da5c52c25ccbd4a4e4297f1846880b1d889a5 Mon Sep 17 00:00:00 2001 From: Patrick Kennel Date: Sun, 16 Aug 2026 09:04:12 -0700 Subject: [PATCH] Add Review mode calendar retrospective --- CLAUDE.md | 6 +- src/App.vue | 13 +- src/components/FileTabBar.vue | 8 + src/components/FocusMode.vue | 31 - src/components/ReviewMode.vue | 879 +++++++++++++++++++++ src/utils/reviewModeHelpers.js | 330 ++++++++ tests/unit/utils/reviewModeHelpers.test.js | 283 +++++++ 7 files changed, 1515 insertions(+), 35 deletions(-) create mode 100644 src/components/ReviewMode.vue create mode 100644 src/utils/reviewModeHelpers.js create mode 100644 tests/unit/utils/reviewModeHelpers.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 1980838..632a36e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,9 @@ A Vue 3 Kanban board whose "database" is one or more plain markdown files. Every - `useTaskDisplay.js` — task filtering and display helpers. - `useTaskSelection.js` — multi-select set of task ids and click handler (normal click, cmd/ctrl click, shift click). - Components (`src/components/`): - - `FileTabBar.vue` — tabs, view-mode buttons (Triage/Plan/Focus), undo/redo, History button. + - `FileTabBar.vue` — tabs, view-mode buttons (Triage/Plan/Focus/Review), undo/redo, History button. The full-screen modes have no close button of their own; the view-mode buttons are the only way in and out. - `KanbanBoard.vue` — renders the five column stacks, owns drag/drop glue, date-picker, archive-confirm modal, context menu, multi-drag handler. Drawer presets per view mode: `triage` (expand TODO/PROJECTS, collapse WIP/DONE) and `plan` (expand SELECTED/WIP, collapse the rest). + - `ReviewMode.vue` — full-screen Review retrospective: a month calendar of what was completed, dropped, and is still due, topped by a clickable Jan–Dec bar chart of its calendar year. The chart spans the calendar's width with the stat tiles in the rail-width slot beside it. The rail carries the selected day's detail, an 8-week throughput trend, section ranking, and still-overdue work, each card shrinking with its own internal scroll so no card header falls below the fold. Read-only. - `FocusMode.vue` — full-screen Focus execution view. Its product behavior and interaction contract live in `docs/focus-mode-spec.md`; read that spec before changing Focus UX or routing. - `KanbanColumn.vue` — single column stack (TODO/PROJECTS/SELECTED/WIP/DONE); tri-state collapse caret; passes multi-drag through. - `KanbanSection.vue` — an H2/H3 section inside a column; hosts `vuedraggable`; handles multi-drag stacking (clones `.task-card.selected` into the drag wrapper, hides originals with `.multi-drag-hidden`). @@ -54,6 +55,7 @@ A Vue 3 Kanban board whose "database" is one or more plain markdown files. Every - Utils (`src/utils/`): - `TodoMdParser.js` — `parseTodoMdFile` / `renderTodoMdFile`, and `COLUMNSTACK_CATEGORIES` (H1 → column-stack keyword mapping). - `focusModeHelpers.js` — pure derivation for Focus mode (`deriveFocusModel`, `findActiveWipSection`, `findQuickAddTarget`). + - `reviewModeHelpers.js` — pure derivation for Review mode (`deriveReviewModel`, `deriveCalendarYearBars`, `deriveWeeklyTrend`, `anchorForTask`, `shiftAnchor`). - `dateHelpers.js`, `completionDateHelpers.js` — date parsing/formatting. - `sectionHelpers.js`, `sortHelpers.js`, `taskTextHelpers.js` — section/task manipulation helpers. @@ -125,7 +127,7 @@ The HistoryPanel uses `GET /api/history` and `GET /api/history/version` to surfa - Drag/drop updates are optimistic (UI first, save follows). - All parse/render logic lives in `TodoMdParser.js`. - Date logic lives in `dateHelpers.js` / `completionDateHelpers.js`. -- Selected-file persistence is in `localStorage` (`selectedTodoFilePath`); view mode is in `localStorage.viewMode` (`normal` / `triage` / `plan` / `focus`). +- Selected-file persistence is in `localStorage` (`selectedTodoFilePath`); view mode is in `localStorage.viewMode` (`normal` / `triage` / `plan` / `focus` / `review`). - WebSocket client uses a checksum comparison against its own `renderTodoMdFile` output to avoid reload storms on self-initiated saves. ## Testing Approach diff --git a/src/App.vue b/src/App.vue index 24196b9..aa6cddd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -41,6 +41,13 @@ @set-view-mode="setViewMode" /> + + Focus + diff --git a/src/components/FocusMode.vue b/src/components/FocusMode.vue index 18040dc..443081a 100644 --- a/src/components/FocusMode.vue +++ b/src/components/FocusMode.vue @@ -17,7 +17,6 @@ -
{ background: #4caf50; } -.focus-exit-btn { - background: transparent; - border: 1px solid #333c49; - border-radius: 6px; - color: #9aa4b2; - width: 30px; - height: 30px; - cursor: pointer; - font-size: 14px; - margin-bottom: 2px; - transition: all 0.2s ease; -} - -.focus-exit-btn:hover { - color: #f4f6f8; - border-color: #556070; - background: #262c36; -} - /* ========================= */ /* Stage + panels */ /* ========================= */ @@ -3404,17 +3384,6 @@ button.focus-week-clock:hover::after { background: #e0e0e0; } -.theme-light .focus-exit-btn { - border-color: #ccc; - color: #666; -} - -.theme-light .focus-exit-btn:hover { - color: #333; - border-color: #aaa; - background: #e8e8e8; -} - .theme-light .focus-quick-add-btn { color: #666; border-color: #ccc; diff --git a/src/components/ReviewMode.vue b/src/components/ReviewMode.vue new file mode 100644 index 0000000..bbaedbd --- /dev/null +++ b/src/components/ReviewMode.vue @@ -0,0 +1,879 @@ + + + + + + + diff --git a/src/utils/reviewModeHelpers.js b/src/utils/reviewModeHelpers.js new file mode 100644 index 0000000..3291b01 --- /dev/null +++ b/src/utils/reviewModeHelpers.js @@ -0,0 +1,330 @@ +// utils/reviewModeHelpers.js +// Pure derivation for Review mode - the retrospective counterpart to Focus. +// Focus asks "what do I do now"; Review asks "what actually happened, and what +// is landing". It reads the whole file (every column, including ARCHIVE) and +// buckets tasks onto calendar days by their authoritative date: completion day +// for terminal tasks, due period for everything still open. + +import { + MONTH_NAMES, + extractDuePeriod, + formatWeekPeriodLabel, + majorityMonthForWeek, + startOfSundayWeek, + weekIdentity +} from './dateHelpers'; +import { extractCompletionDateValue } from './completionDateHelpers'; + +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export const MONTHS_IN_YEAR = 12; + +const atStartOfDay = (date) => { + const result = new Date(date); + result.setHours(0, 0, 0, 0); + return result; +}; + +const atEndOfDay = (date) => { + const result = new Date(date); + result.setHours(23, 59, 59, 999); + return result; +}; + +const addDays = (date, days) => { + const result = atStartOfDay(date); + result.setDate(result.getDate() + days); + return result; +}; + +export const dayKey = (date) => { + const day = atStartOfDay(date); + return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`; +}; + +const isTaskItem = (item) => item?.type === 'task'; + +const statusBucket = (statusChar) => { + if (statusChar === 'x') return 'completed'; + if (statusChar === '-') return 'cancelled'; + if (statusChar === '~') return 'inProgress'; + return 'queued'; +}; + +const isTerminalBucket = (bucket) => bucket === 'completed' || bucket === 'cancelled'; + +const eachTask = (todoData, visit) => { + (todoData?.columnOrder || []).forEach(columnName => { + const column = todoData.columnStacks?.[columnName]; + if (!column || column.type === 'raw-text') return; + + (column.sections || []).forEach(section => { + if (section.type === 'raw-text') return; + (section.items || []).filter(isTaskItem).forEach(task => { + visit({ task, columnName, stackName: column.name, sectionName: section.name }); + }); + }); + }); +}; + +/** + * The authoritative calendar anchor for one task. + * Terminal tasks are anchored on the day they were finished; open tasks are + * anchored on their due period. Either may be missing, which is itself a fact + * worth reporting rather than a reason to drop the task. + */ +export const anchorForTask = (task) => { + const bucket = statusBucket(task.statusChar); + if (isTerminalBucket(bucket)) { + const completed = extractCompletionDateValue(task.text); + return completed ? { kind: 'day', start: completed, end: completed, source: 'completion' } : null; + } + const period = extractDuePeriod(task.text); + return period ? { ...period, source: 'due' } : null; +}; + +/** The month a due period belongs to: for weeks, the month holding four of its days. */ +const owningMonth = (anchor) => anchor.kind === 'week' + ? majorityMonthForWeek(anchor.start) + : { year: anchor.start.getFullYear(), monthIndex: anchor.start.getMonth() }; + +/** The month holding the anchor, padded out to whole Sunday-Saturday weeks. */ +const monthBounds = (anchorDate) => { + const monthStart = new Date(anchorDate.getFullYear(), anchorDate.getMonth(), 1); + const monthEnd = new Date(anchorDate.getFullYear(), anchorDate.getMonth() + 1, 0); + return { + periodStart: atStartOfDay(monthStart), + periodEnd: atStartOfDay(monthEnd), + gridStart: startOfSundayWeek(monthStart), + gridEnd: addDays(startOfSundayWeek(monthEnd), 6) + }; +}; + +export const shiftAnchor = (anchorDate, steps) => + new Date(anchorDate.getFullYear(), anchorDate.getMonth() + steps, 1); + +export const periodLabelFor = (anchorDate) => + `${anchorDate.toLocaleDateString('en-US', { month: 'long' })} ${anchorDate.getFullYear()}`; + +export const periodSubLabelFor = (periodStart, periodEnd) => + `${MONTH_NAMES[periodStart.getMonth()]} 1 – ${periodEnd.getDate()}`; + +const emptyDay = (date, periodStart, periodEnd, today) => ({ + date, + key: dayKey(date), + dayOfMonth: date.getDate(), + weekdayLabel: DAY_NAMES[date.getDay()], + label: `${DAY_NAMES[date.getDay()]} ${date.getDate()}`, + isToday: date.getTime() === today.getTime(), + isFuture: date > today, + isOutsidePeriod: date < periodStart || date > periodEnd, + completed: [], + cancelled: [], + due: [], + inProgress: [] +}); + +const fileEntry = (bucket, entry) => { + if (entry.bucket === 'completed') bucket.completed.push(entry); + else if (entry.bucket === 'cancelled') bucket.cancelled.push(entry); + else { + bucket.due.push(entry); + if (entry.bucket === 'inProgress') bucket.inProgress.push(entry); + } +}; + +const rankSection = (counts, entry) => { + const key = `${entry.columnName} › ${entry.sectionName}`; + counts.set(key, (counts.get(key) || 0) + 1); +}; + +/** + * Build the review model for one calendar month. + * @param {Object} todoData - { columnOrder, columnStacks } + * @param {Object} [options] + * @param {Date} [options.anchor] - any day inside the month under review + * @param {Date} [options.today] - injectable clock for deterministic tests + * @returns {Object} grid, per-day buckets, totals and highlights + */ +export const deriveReviewModel = (todoData, { anchor = new Date(), today = new Date() } = {}) => { + const anchorDate = atStartOfDay(anchor); + const currentDay = atStartOfDay(today); + const { periodStart, periodEnd, gridStart, gridEnd } = monthBounds(anchorDate); + + const days = []; + const dayIndex = new Map(); + for (let cursor = new Date(gridStart); cursor <= gridEnd; cursor = addDays(cursor, 1)) { + const day = emptyDay(new Date(cursor), periodStart, periodEnd, currentDay); + days.push(day); + dayIndex.set(day.key, day); + } + + const spanning = []; + const undated = { completed: [], cancelled: [], open: [] }; + const sectionCounts = new Map(); + const overdueOpen = []; + + eachTask(todoData, (entry) => { + const bucket = statusBucket(entry.task.statusChar); + const taskAnchor = anchorForTask(entry.task); + const enriched = { ...entry, bucket, anchor: taskAnchor }; + + if (!taskAnchor) { + if (bucket === 'completed') undated.completed.push(enriched); + else if (bucket === 'cancelled') undated.cancelled.push(enriched); + else undated.open.push(enriched); + return; + } + + if (!isTerminalBucket(bucket) && taskAnchor.end < currentDay) overdueOpen.push(enriched); + if (taskAnchor.start > atEndOfDay(gridEnd) || taskAnchor.end < gridStart) return; + + // Week and month due periods have no honest single-day slot; they belong to + // the period as a whole and are listed alongside the grid instead. + if (taskAnchor.kind !== 'day') { + spanning.push(enriched); + return; + } + + const day = dayIndex.get(dayKey(taskAnchor.start)); + if (!day) return; + + fileEntry(day, enriched); + if (bucket === 'completed') rankSection(sectionCounts, entry); + }); + + const inPeriod = days.filter(day => !day.isOutsidePeriod); + const sum = (pick) => inPeriod.reduce((total, day) => total + pick(day).length, 0); + + const completed = sum(day => day.completed); + const cancelled = sum(day => day.cancelled); + const due = sum(day => day.due); + const inProgress = sum(day => day.inProgress); + const spanningDue = spanning.filter(entry => !isTerminalBucket(entry.bucket)).length; + const commitments = completed + cancelled + due + spanningDue; + + const elapsedDays = inPeriod.filter(day => !day.isFuture).length; + const activeDays = inPeriod.filter(day => day.completed.length > 0).length; + const peakDay = inPeriod.reduce( + (best, day) => (day.completed.length > (best?.completed.length ?? 0) ? day : best), + null + ); + + return { + anchor: anchorDate, + today: currentDay, + periodStart, + periodEnd, + gridStart, + gridEnd, + label: periodLabelFor(anchorDate), + subLabel: periodSubLabelFor(periodStart, periodEnd), + isCurrentPeriod: currentDay >= periodStart && currentDay <= periodEnd, + days, + weeks: Array.from({ length: days.length / 7 }, (_, week) => days.slice(week * 7, week * 7 + 7)), + weekdayLabels: DAY_NAMES, + spanning, + undated, + overdueOpen, + totals: { + completed, + cancelled, + due, + inProgress, + spanningDue, + resolved: completed + cancelled, + commitments, + overdueOpen: overdueOpen.length, + completionRate: commitments ? Math.round((completed / commitments) * 100) : 0, + dailyAverage: elapsedDays ? Math.round((completed / elapsedDays) * 10) / 10 : 0, + activeDays, + elapsedDays, + busiestDayLabel: peakDay?.completed.length ? peakDay.label : '—', + busiestDayCount: peakDay?.completed.length || 0, + peakDayCount: inPeriod.reduce( + (max, day) => Math.max(max, day.completed.length + day.cancelled.length + day.due.length), + 0 + ) + }, + topSections: [...sectionCounts.entries()] + .map(([label, count]) => ({ label, count })) + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)) + .slice(0, 5) + }; +}; + +/** + * January-to-December bars for one calendar year, for the strip above the month + * grid. Month buckets can hold week and month due periods honestly - a week + * belongs to the month holding four of its days - so nothing is set aside here. + * @param {Object} todoData - { columnOrder, columnStacks } + * @param {Object} [options] + * @param {number} [options.year] - calendar year to chart + * @param {Date} [options.today] - injectable clock for deterministic tests + * @returns {{ year: number, months: Array, peak: number, totals: Object }} + */ +export const deriveCalendarYearBars = (todoData, { year = new Date().getFullYear(), today = new Date() } = {}) => { + const currentDay = atStartOfDay(today); + + const months = Array.from({ length: MONTHS_IN_YEAR }, (_, monthIndex) => ({ + key: `${year}-${String(monthIndex + 1).padStart(2, '0')}`, + year, + monthIndex, + date: new Date(year, monthIndex, 1), + label: `${MONTH_NAMES[monthIndex]} ${year}`, + monthLabel: MONTH_NAMES[monthIndex], + isCurrent: year === currentDay.getFullYear() && monthIndex === currentDay.getMonth(), + isFuture: new Date(year, monthIndex, 1) > new Date(currentDay.getFullYear(), currentDay.getMonth(), 1), + completed: [], + cancelled: [], + due: [], + inProgress: [] + })); + + eachTask(todoData, (entry) => { + const bucket = statusBucket(entry.task.statusChar); + const taskAnchor = anchorForTask(entry.task); + if (!taskAnchor) return; + + const owner = owningMonth(taskAnchor); + if (owner.year !== year) return; + fileEntry(months[owner.monthIndex], { ...entry, bucket, anchor: taskAnchor }); + }); + + const totalOf = (month) => month.completed.length + month.cancelled.length + month.due.length; + + return { + year, + months, + peak: months.reduce((max, month) => Math.max(max, totalOf(month)), 0), + totals: { + completed: months.reduce((total, month) => total + month.completed.length, 0), + cancelled: months.reduce((total, month) => total + month.cancelled.length, 0), + due: months.reduce((total, month) => total + month.due.length, 0) + } + }; +}; + +/** + * Completions per Sunday-Saturday week across the trailing weeks ending with + * the review period, for the sparkline strip. + */ +export const deriveWeeklyTrend = (todoData, { anchor = new Date(), weeks = 8 } = {}) => { + const anchorSunday = startOfSundayWeek(anchor); + const buckets = Array.from({ length: weeks }, (_, offset) => { + const start = addDays(anchorSunday, (offset - (weeks - 1)) * 7); + return { start, end: addDays(start, 6), label: formatWeekPeriodLabel(start), completed: 0, cancelled: 0 }; + }); + + eachTask(todoData, ({ task }) => { + const bucket = statusBucket(task.statusChar); + if (!isTerminalBucket(bucket)) return; + const completedOn = extractCompletionDateValue(task.text); + if (!completedOn) return; + const slot = buckets.find(week => completedOn >= week.start && completedOn <= week.end); + if (slot) slot[bucket] += 1; + }); + + const peak = buckets.reduce((max, week) => Math.max(max, week.completed + week.cancelled), 0); + return { buckets, peak, currentWeekNumber: weekIdentity(anchorSunday).number }; +}; diff --git a/tests/unit/utils/reviewModeHelpers.test.js b/tests/unit/utils/reviewModeHelpers.test.js new file mode 100644 index 0000000..3f7f7fe --- /dev/null +++ b/tests/unit/utils/reviewModeHelpers.test.js @@ -0,0 +1,283 @@ +import { describe, it, expect } from 'vitest'; +import { parseTodoMdFile } from '../../../src/utils/TodoMdParser'; +import { + anchorForTask, + dayKey, + deriveCalendarYearBars, + deriveReviewModel, + deriveWeeklyTrend, + periodLabelFor, + shiftAnchor +} from '../../../src/utils/reviewModeHelpers'; + +// Wednesday 12 August 2026, so "this week" runs Sun 9th - Sat 15th +const WEDNESDAY = new Date(2026, 7, 12, 10, 0, 0); + +const fixture = `# SELECTED +## Plans +* [ ] Due Friday this week ! Aug 14 2026 +* [~] Started, due today ! Aug 12 2026 +* [ ] Overdue and open ! Aug 5 2026 +* [ ] Due next month ! Sep 3 2026 +* [ ] Undated and open +* [ ] Whole week commitment ! Aug Week #2 2026 +* [ ] Whole month commitment ! Aug 2026 + +# WIP +### CURRENT +* [x] Finished Monday | Aug 10 2026 +* [x] Finished Wednesday | Aug 12 2026 +* [-] Dropped Wednesday | Aug 12 2026 + +# ARCHIVE +### Aug Week #2 2026 +* [x] Archived Wednesday | Aug 12 2026 +* [x] Archived last week | Aug 6 2026 +* [x] Archived with no completion date +`; + +const todoData = parseTodoMdFile(fixture); + +const findDay = (model, key) => model.days.find(day => day.key === key); + +describe('deriveReviewModel', () => { + const model = deriveReviewModel(todoData, { anchor: WEDNESDAY, today: WEDNESDAY }); + + it('pads the anchor month out to whole Sunday-Saturday weeks and flags padding days', () => { + expect(model.days.length % 7).toBe(0); + expect(model.gridStart.getDay()).toBe(0); + expect(model.weeks.every(week => week.length === 7)).toBe(true); + expect(findDay(model, '2026-07-31').isOutsidePeriod).toBe(true); + expect(findDay(model, '2026-08-01').isOutsidePeriod).toBe(false); + }); + + it('labels the month and marks it current', () => { + expect(model.label).toBe('August 2026'); + expect(model.subLabel).toBe('Aug 1 – 31'); + expect(model.isCurrentPeriod).toBe(true); + }); + + it('buckets terminal tasks onto their completion day, from every column', () => { + const wednesday = findDay(model, '2026-08-12'); + expect(wednesday.completed.map(entry => entry.task.displayText)).toEqual([ + 'Finished Wednesday', + 'Archived Wednesday' + ]); + expect(wednesday.cancelled.map(entry => entry.task.displayText)).toEqual(['Dropped Wednesday']); + expect(findDay(model, '2026-08-10').completed).toHaveLength(1); + }); + + it('buckets open tasks onto their exact due day and tracks in-progress separately', () => { + const today = findDay(model, '2026-08-12'); + expect(today.due.map(entry => entry.task.displayText)).toEqual(['Started, due today']); + expect(today.inProgress).toHaveLength(1); + expect(findDay(model, '2026-08-14').due.map(entry => entry.task.displayText)).toEqual(['Due Friday this week']); + }); + + it('keeps week and month commitments out of the day grid and in the spanning list', () => { + expect(model.spanning.map(entry => entry.task.displayText)).toEqual([ + 'Whole week commitment', + 'Whole month commitment' + ]); + expect(model.days.flatMap(day => day.due).map(entry => entry.task.displayText)) + .not.toContain('Whole week commitment'); + }); + + it('covers the whole month, not just the week holding today', () => { + expect(findDay(model, '2026-08-06').completed).toHaveLength(1); + expect(findDay(model, '2026-08-05').due.map(entry => entry.task.displayText)).toEqual(['Overdue and open']); + }); + + it('renders work landing in the padding days but leaves it out of the totals', () => { + // August's grid runs Jul 26 - Sep 5, so Sep 3 shows in the trailing row. + const september3 = findDay(model, '2026-09-03'); + expect(september3.isOutsidePeriod).toBe(true); + expect(september3.due.map(entry => entry.task.displayText)).toEqual(['Due next month']); + expect(model.totals.due).toBe(3); + }); + + it('keeps dateless work off the grid entirely', () => { + const texts = model.days.flatMap(day => [...day.completed, ...day.cancelled, ...day.due]) + .map(entry => entry.task.displayText); + expect(texts).not.toContain('Archived with no completion date'); + expect(texts).not.toContain('Undated and open'); + }); + + it('collects dateless tasks instead of dropping them', () => { + expect(model.undated.completed.map(entry => entry.task.displayText)).toEqual(['Archived with no completion date']); + expect(model.undated.open.map(entry => entry.task.displayText)).toEqual(['Undated and open']); + }); + + it('reports still-overdue open work regardless of the period on screen', () => { + expect(model.overdueOpen.map(entry => entry.task.displayText)).toEqual(['Overdue and open']); + expect(model.totals.overdueOpen).toBe(1); + }); + + it('totals throughput, commitments and derived rates over the month', () => { + expect(model.totals.completed).toBe(4); + expect(model.totals.cancelled).toBe(1); + expect(model.totals.due).toBe(3); + expect(model.totals.spanningDue).toBe(2); + expect(model.totals.commitments).toBe(10); + expect(model.totals.completionRate).toBe(40); + expect(model.totals.activeDays).toBe(3); + // Aug 1 through Aug 12 have elapsed; the 13th onward has not. + expect(model.totals.elapsedDays).toBe(12); + expect(model.totals.dailyAverage).toBe(0.3); + expect(model.totals.busiestDayLabel).toBe('Wed 12'); + expect(model.totals.busiestDayCount).toBe(2); + }); + + it('ranks the sections that completed work came from', () => { + expect(model.topSections).toEqual([ + { label: 'ARCHIVE › Aug Week #2 2026', count: 2 }, + { label: 'WIP › CURRENT', count: 2 } + ]); + }); + + it('marks today and future days', () => { + expect(findDay(model, '2026-08-12').isToday).toBe(true); + expect(findDay(model, '2026-08-12').isFuture).toBe(false); + expect(findDay(model, '2026-08-14').isFuture).toBe(true); + expect(findDay(model, '2026-08-10').isFuture).toBe(false); + }); + + it('excludes padding days from the totals', () => { + const september = deriveReviewModel(todoData, { anchor: new Date(2026, 8, 15), today: WEDNESDAY }); + // September's grid opens on Aug 30, so August's work is out of reach of it. + expect(september.gridStart.getTime()).toBe(new Date(2026, 7, 30).getTime()); + expect(findDay(september, '2026-09-03').due).toHaveLength(1); + expect(september.totals.completed).toBe(0); + expect(september.totals.due).toBe(1); + expect(september.isCurrentPeriod).toBe(false); + }); +}); + +describe('deriveCalendarYearBars', () => { + const bars = deriveCalendarYearBars(todoData, { year: 2026, today: WEDNESDAY }); + const month = (index) => bars.months[index]; + + it('covers January through December of the requested calendar year', () => { + expect(bars.months).toHaveLength(12); + expect(bars.year).toBe(2026); + expect(month(0).key).toBe('2026-01'); + expect(month(11).key).toBe('2026-12'); + expect(month(7).monthLabel).toBe('Aug'); + expect(month(7).label).toBe('Aug 2026'); + }); + + it('rolls every day of a month into that month bar', () => { + expect(month(7).completed.map(entry => entry.task.displayText)).toEqual([ + 'Finished Monday', + 'Finished Wednesday', + 'Archived Wednesday', + 'Archived last week' + ]); + expect(month(7).cancelled).toHaveLength(1); + }); + + it('absorbs week and month due periods into their owning month', () => { + expect(month(7).due.map(entry => entry.task.displayText)).toEqual([ + 'Due Friday this week', + 'Started, due today', + 'Overdue and open', + 'Whole week commitment', + 'Whole month commitment' + ]); + expect(month(7).inProgress).toHaveLength(1); + }); + + it('separates months that are not the anchor month', () => { + expect(month(8).due.map(entry => entry.task.displayText)).toEqual(['Due next month']); + expect(month(0).completed).toEqual([]); + }); + + it('marks the current month and the months still ahead', () => { + expect(month(7).isCurrent).toBe(true); + expect(month(6).isCurrent).toBe(false); + expect(month(6).isFuture).toBe(false); + expect(month(8).isFuture).toBe(true); + }); + + it('reports the peak bar and year totals for scaling and the summary line', () => { + expect(bars.peak).toBe(10); + expect(bars.totals).toEqual({ completed: 4, cancelled: 1, due: 6 }); + }); + + it('returns an empty year rather than failing on other years or no data', () => { + const empty = deriveCalendarYearBars(todoData, { year: 2024, today: WEDNESDAY }); + expect(empty.months).toHaveLength(12); + expect(empty.peak).toBe(0); + expect(empty.totals).toEqual({ completed: 0, cancelled: 0, due: 0 }); + expect(empty.months.every(bar => bar.isFuture === false)).toBe(true); + + const none = deriveCalendarYearBars(null, { year: 2026, today: WEDNESDAY }); + expect(none.peak).toBe(0); + }); +}); + +describe('empty and malformed input', () => { + it('returns a usable model with no data', () => { + const model = deriveReviewModel(null, { anchor: WEDNESDAY, today: WEDNESDAY }); + expect(model.days.length % 7).toBe(0); + expect(model.totals.completed).toBe(0); + expect(model.totals.completionRate).toBe(0); + expect(model.totals.busiestDayLabel).toBe('—'); + }); + + it('ignores raw-text columns and sections', () => { + const data = parseTodoMdFile('stray line before any column\n\n# WIP\n### CURRENT\n* [x] Real one | Aug 12 2026\nloose text\n'); + const model = deriveReviewModel(data, { anchor: WEDNESDAY, today: WEDNESDAY }); + expect(model.totals.completed).toBe(1); + }); +}); + +describe('anchorForTask', () => { + it('anchors terminal tasks on their completion day', () => { + expect(anchorForTask({ statusChar: 'x', text: 'Done | Aug 12 2026' })).toMatchObject({ kind: 'day', source: 'completion' }); + expect(anchorForTask({ statusChar: '-', text: 'Dropped | Aug 12 2026' }).source).toBe('completion'); + }); + + it('anchors open tasks on their due period', () => { + expect(anchorForTask({ statusChar: '~', text: 'Open ! Aug 2026' })).toMatchObject({ kind: 'month', source: 'due' }); + }); + + it('returns null when the authoritative date is missing', () => { + expect(anchorForTask({ statusChar: 'x', text: 'Done but undated' })).toBeNull(); + expect(anchorForTask({ statusChar: ' ', text: 'Open but undated' })).toBeNull(); + }); +}); + +describe('navigation helpers', () => { + it('steps by whole months, landing on the first', () => { + expect(dayKey(shiftAnchor(WEDNESDAY, 1))).toBe('2026-09-01'); + expect(dayKey(shiftAnchor(WEDNESDAY, -8))).toBe('2025-12-01'); + expect(dayKey(shiftAnchor(WEDNESDAY, 0))).toBe('2026-08-01'); + }); + + it('labels the month', () => { + expect(periodLabelFor(WEDNESDAY)).toBe('August 2026'); + expect(periodLabelFor(new Date(2026, 0, 31))).toBe('January 2026'); + }); +}); + +describe('deriveWeeklyTrend', () => { + const trend = deriveWeeklyTrend(todoData, { anchor: WEDNESDAY, weeks: 4 }); + + it('ends on the anchor week and counts backwards', () => { + expect(trend.buckets).toHaveLength(4); + expect(dayKey(trend.buckets[3].start)).toBe('2026-08-09'); + expect(dayKey(trend.buckets[0].start)).toBe('2026-07-19'); + }); + + it('separates completed from cancelled per week', () => { + expect(trend.buckets[3]).toMatchObject({ completed: 3, cancelled: 1 }); + expect(trend.buckets[2]).toMatchObject({ completed: 1, cancelled: 0 }); + expect(trend.peak).toBe(4); + }); + + it('ignores open tasks and undated completions', () => { + const totalCounted = trend.buckets.reduce((sum, week) => sum + week.completed + week.cancelled, 0); + expect(totalCounted).toBe(5); + }); +});