diff --git a/docs/superpowers/plans/2026-08-15-fix-and-harden.md b/docs/superpowers/plans/2026-08-15-fix-and-harden.md index 3fba186..b9f3066 100644 --- a/docs/superpowers/plans/2026-08-15-fix-and-harden.md +++ b/docs/superpowers/plans/2026-08-15-fix-and-harden.md @@ -731,7 +731,7 @@ export function msUntilNextMidnight(now) { `listTaskKeys` already filters to well-formed ISO keys, so the `< todayKey` string comparison is safe — and sortable keys are exactly why the format changed. Building next midnight from local calendar parts keeps it DST-correct: on a spring-forward day the interval is genuinely 23 hours, and this returns 23 hours. -Note the empty-carry case: when every prior day held only completed tasks, `carried` is empty but the prior keys are still deleted, and today's list is rewritten unchanged. That is intended — pruning is the point. +Note the empty-carry case: when every prior day held only completed tasks, the prior keys are still deleted but today's key is left alone — writing an empty array would create a key for nothing, and rollover runs on every focus and visibility change. Persist only when something actually moved. - [ ] **Step 4: Run to verify pass** diff --git a/src/lib/rollover.js b/src/lib/rollover.js new file mode 100644 index 0000000..cb3aa7c --- /dev/null +++ b/src/lib/rollover.js @@ -0,0 +1,52 @@ +import { toKey } from './dates.js' + +/** + * Applies the day-boundary rule and returns today's resulting task list. + * + * For every stored day earlier than `now`: unfinished tasks are carried into + * today (oldest day first, within-day order preserved, ahead of anything + * already in today), completed tasks are discarded, and the old key is + * deleted. Pruning is therefore a side effect of carrying, so there is no + * separate retention policy. Future keys — Tomorrow — are never touched. + * + * `now` is a parameter rather than a `new Date()` call so the boundary is + * testable. This replaces `checkAndMigrateTasks`, whose guard compared a + * timestamp against itself and so never once executed. + */ +export function rollover(storage, now) { + const todayKey = toKey(now) + + // listTaskKeys yields only well-formed ISO keys, which is what makes this + // string comparison safe — and is why the key format changed. + const pastKeys = storage + .listTaskKeys() + .filter((key) => key < todayKey) + .sort() + + if (pastKeys.length === 0) return storage.loadTasks(todayKey) + + const carried = [] + for (const key of pastKeys) { + carried.push(...storage.loadTasks(key).filter((task) => !task.completed)) + storage.removeTasks(key) + } + + const merged = [...carried, ...storage.loadTasks(todayKey)] + + // Only persist when something actually moved. Prior days holding nothing but + // completed tasks still get pruned above, but today's list is unchanged, and + // rollover runs on every focus and visibility change. + if (carried.length > 0) storage.saveTasks(todayKey, merged) + + return merged +} + +/** + * Milliseconds until the next local midnight, for scheduling the rollover + * timer. Built from local calendar parts, so a spring-forward day correctly + * yields 23 hours rather than a flat 24. + */ +export function msUntilNextMidnight(now) { + const next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1) + return Math.max(1000, next.getTime() - now.getTime()) +} diff --git a/src/lib/rollover.test.js b/src/lib/rollover.test.js new file mode 100644 index 0000000..dc67d2b --- /dev/null +++ b/src/lib/rollover.test.js @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest' +import { createStorage, createMemoryStore } from './storage.js' +import { rollover, msUntilNextMidnight } from './rollover.js' + +const task = (id, completed = false) => ({ id, text: id, completed }) + +const setup = (seed = {}) => { + const backend = createMemoryStore() + for (const [dateKey, tasks] of Object.entries(seed)) { + backend.setItem(`negotium-tasks-${dateKey}`, JSON.stringify(tasks)) + } + return createStorage(backend) +} + +describe('rollover', () => { + it('is a no-op when only today has tasks', () => { + const storage = setup({ '2026-08-15': [task('a')] }) + const result = rollover(storage, new Date(2026, 7, 15, 9, 0)) + expect(result.map((t) => t.id)).toEqual(['a']) + expect(storage.listTaskKeys()).toEqual(['2026-08-15']) + }) + + it('carries unfinished tasks forward from yesterday', () => { + const storage = setup({ '2026-08-14': [task('old')] }) + const result = rollover(storage, new Date(2026, 7, 15, 9, 0)) + expect(result.map((t) => t.id)).toEqual(['old']) + expect(storage.loadTasks('2026-08-15').map((t) => t.id)).toEqual(['old']) + }) + + it('drops completed tasks from prior days', () => { + const storage = setup({ '2026-08-14': [task('done', true), task('open')] }) + const result = rollover(storage, new Date(2026, 7, 15, 9, 0)) + expect(result.map((t) => t.id)).toEqual(['open']) + }) + + it('deletes prior-day keys after carrying', () => { + const storage = setup({ '2026-08-14': [task('old')] }) + rollover(storage, new Date(2026, 7, 15, 9, 0)) + expect(storage.listTaskKeys()).toEqual(['2026-08-15']) + }) + + it('spans a multi-day gap, oldest day first', () => { + const storage = setup({ + '2026-08-12': [task('mon')], + '2026-08-13': [task('tue')], + '2026-08-14': [task('wed')], + }) + const result = rollover(storage, new Date(2026, 7, 15, 9, 0)) + expect(result.map((t) => t.id)).toEqual(['mon', 'tue', 'wed']) + }) + + it('preserves within-day order', () => { + const storage = setup({ '2026-08-14': [task('first'), task('second'), task('third')] }) + const result = rollover(storage, new Date(2026, 7, 15, 9, 0)) + expect(result.map((t) => t.id)).toEqual(['first', 'second', 'third']) + }) + + it('places carried tasks above tasks already in today', () => { + const storage = setup({ + '2026-08-14': [task('carried')], + '2026-08-15': [task('existing')], + }) + const result = rollover(storage, new Date(2026, 7, 15, 9, 0)) + expect(result.map((t) => t.id)).toEqual(['carried', 'existing']) + }) + + it('never touches future keys', () => { + const storage = setup({ + '2026-08-15': [task('today')], + '2026-08-16': [task('tomorrow')], + }) + rollover(storage, new Date(2026, 7, 15, 9, 0)) + expect(storage.loadTasks('2026-08-16').map((t) => t.id)).toEqual(['tomorrow']) + }) + + it('handles prior days that hold only completed tasks', () => { + const storage = setup({ '2026-08-14': [task('done', true)] }) + const result = rollover(storage, new Date(2026, 7, 15, 9, 0)) + expect(result).toEqual([]) + expect(storage.listTaskKeys()).toEqual([]) + }) + + it('returns an empty list when nothing is stored at all', () => { + const storage = setup() + expect(rollover(storage, new Date(2026, 7, 15, 9, 0))).toEqual([]) + }) + + it('carries across a month boundary', () => { + const storage = setup({ '2026-07-31': [task('july')] }) + const result = rollover(storage, new Date(2026, 7, 1, 9, 0)) + expect(result.map((t) => t.id)).toEqual(['july']) + }) + + it('carries across a year boundary', () => { + const storage = setup({ '2026-12-31': [task('nye')] }) + const result = rollover(storage, new Date(2027, 0, 1, 9, 0)) + expect(result.map((t) => t.id)).toEqual(['nye']) + }) + + it('is idempotent when run twice on the same day', () => { + const storage = setup({ '2026-08-14': [task('old')] }) + const now = new Date(2026, 7, 15, 9, 0) + rollover(storage, now) + const second = rollover(storage, now) + expect(second.map((t) => t.id)).toEqual(['old']) + expect(storage.listTaskKeys()).toEqual(['2026-08-15']) + }) + + it('leaves an unparseable legacy key in place rather than treating it as past', () => { + const backend = createMemoryStore() + backend.setItem('negotium-tasks-not-a-date', '[{"id":"x"}]') + const storage = createStorage(backend) + rollover(storage, new Date(2026, 7, 15, 9, 0)) + expect(backend.getItem('negotium-tasks-not-a-date')).toBe('[{"id":"x"}]') + }) +}) + +describe('msUntilNextMidnight', () => { + it('counts down to the next local midnight', () => { + expect(msUntilNextMidnight(new Date(2026, 7, 15, 23, 0, 0))).toBe(60 * 60 * 1000) + }) + + it('returns a full day just after midnight', () => { + expect(msUntilNextMidnight(new Date(2026, 7, 15, 0, 0, 0))).toBe(24 * 60 * 60 * 1000) + }) + + it('never returns a non-positive value', () => { + expect(msUntilNextMidnight(new Date(2026, 7, 15, 23, 59, 59, 999))).toBeGreaterThan(0) + }) + + it('lands exactly on the next calendar day', () => { + const now = new Date(2026, 7, 15, 17, 42, 13) + const landing = new Date(now.getTime() + msUntilNextMidnight(now)) + expect(landing.getDate()).toBe(16) + expect(landing.getHours()).toBe(0) + expect(landing.getMinutes()).toBe(0) + }) + + it('lands on the next calendar day across a month boundary', () => { + const now = new Date(2026, 7, 31, 20, 0, 0) + const landing = new Date(now.getTime() + msUntilNextMidnight(now)) + expect(landing.getMonth()).toBe(8) + expect(landing.getDate()).toBe(1) + }) +})