{
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 @@
+
+
+
+
+
+
+
+
+
+
+ Stats
+
+
+
{{ tile.value }}
+
{{ tile.label }}
+
{{ tile.hint }}
+
+
+
+
+
+ {{ monthName }}
+
+
+
+
+
+
+
+
+ {{ day.dayOfMonth }}
+ {{ bucketTotal(day) }}
+
+
+
+
+
+
+
+
+
+ {{ day.completed.length }}
+ {{ day.cancelled.length }}
+ {{ day.due.length }}
+
+
+
+
+
+
+
+
+
+
+
+
Throughput last 8 weeks
+
+
+
+
+
+
+
+
{{ week.completed + week.cancelled }}
+
+
+
+
+
+
+
Where work landed this month
+
+
+
+ {{ section.label }}
+
+
+
+ {{ section.count }}
+
+
+
+
+
+
+
+
Completed in {{ monthName }}
+ {{ monthCompletedCount }}
+
+ Nothing completed this month yet.
+
+
{{ week.label }}
+
+
{{ day.label }}
+
+
+
+ {{ entry.task.displayText }}
+
+
+
+
+
+
+
+
+
{{ selectedBucketHeading }}
+ ✕
+
+ Nothing recorded on this day.
+
+
{{ group.label }} · {{ group.entries.length }}
+
+
+
+
+ {{ entry.task.displayText }}
+ ({{ entry.sectionName }})
+
+
+
+
+
+
+
+
+
+
+
+
+
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);
+ });
+});