mirror of
https://github.com/aculix/negotium.git
synced 2026-09-11 07:28:17 +00:00
feat: add day-rollover with multi-day carry-forward
Replaces checkAndMigrateTasks, which never ran. Its guard compared currentDate, set from new Date() at init, against a fresh new Date() a few milliseconds later in onMount, so it was never true. Unfinished tasks have been sitting under old date keys ever since. The rule now: unfinished tasks from every prior day move into today, oldest first, ahead of whatever is already there. Completed ones are dropped and the old key deleted. It spans gaps, which the original never did even in theory, since it only looked one day back. now is a parameter. That's the part that makes the boundary testable.
This commit is contained in:
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user