diff --git a/src/lib/storage.js b/src/lib/storage.js new file mode 100644 index 0000000..29ced0e --- /dev/null +++ b/src/lib/storage.js @@ -0,0 +1,144 @@ +import { isKey, toKey } from './dates.js' + +const TASK_PREFIX = 'negotium-tasks-' +const THEME_KEY = 'negotium-theme' + +/** Backend used in tests, and as the production fallback when localStorage is + * unavailable (Safari private mode, disabled storage, exhausted quota). */ +export function createMemoryStore() { + const map = new Map() + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => { + map.set(key, String(value)) + }, + removeItem: (key) => { + map.delete(key) + }, + keys: () => [...map.keys()], + } +} + +function wrapWebStorage(webStorage) { + return { + getItem: (key) => webStorage.getItem(key), + setItem: (key, value) => webStorage.setItem(key, value), + removeItem: (key) => webStorage.removeItem(key), + keys: () => Object.keys(webStorage), + } +} + +function resolveBackend() { + try { + const probe = '__negotium_probe__' + window.localStorage.setItem(probe, '1') + window.localStorage.removeItem(probe) + return wrapWebStorage(window.localStorage) + } catch { + return createMemoryStore() + } +} + +export function createStorage(backend) { + const store = backend ?? resolveBackend() + + function read(key) { + try { + return store.getItem(key) + } catch { + return null + } + } + + function write(key, value) { + try { + store.setItem(key, value) + } catch { + // Quota exhausted or storage revoked mid-session. In-memory state stays + // authoritative; losing a write beats losing the app. + } + } + + function drop(key) { + try { + store.removeItem(key) + } catch { + // Nothing actionable. + } + } + + function allKeys() { + try { + return store.keys() + } catch { + return [] + } + } + + function parseTasks(raw) { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } + } + + function loadTasks(dateKey) { + return parseTasks(read(TASK_PREFIX + dateKey)) + } + + function saveTasks(dateKey, tasks) { + write(TASK_PREFIX + dateKey, JSON.stringify(tasks)) + } + + function taskSuffixes() { + return allKeys() + .filter((key) => key.startsWith(TASK_PREFIX)) + .map((key) => key.slice(TASK_PREFIX.length)) + } + + function listTaskKeys() { + return taskSuffixes().filter(isKey) + } + + function removeTasks(dateKey) { + drop(TASK_PREFIX + dateKey) + } + + function loadTheme() { + return read(THEME_KEY) + } + + function saveTheme(mode) { + write(THEME_KEY, mode) + } + + /** One-time conversion of `negotium-tasks-Sat Aug 15 2026` keys written by + * versions before the ISO format. Idempotent, and deliberately conservative: + * a suffix that will not parse is left in place rather than discarded. */ + function migrateLegacyKeys() { + for (const suffix of taskSuffixes().filter((s) => !isKey(s))) { + const parsed = new Date(suffix) + if (Number.isNaN(parsed.getTime())) continue + + const isoKey = toKey(parsed) + const legacy = parseTasks(read(TASK_PREFIX + suffix)) + const existing = loadTasks(isoKey) + + saveTasks(isoKey, [...legacy, ...existing]) + drop(TASK_PREFIX + suffix) + } + } + + return { + loadTasks, + saveTasks, + listTaskKeys, + removeTasks, + loadTheme, + saveTheme, + migrateLegacyKeys, + } +} diff --git a/src/lib/storage.test.js b/src/lib/storage.test.js new file mode 100644 index 0000000..959bd97 --- /dev/null +++ b/src/lib/storage.test.js @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest' +import { createStorage, createMemoryStore } from './storage.js' + +const setup = (seed = {}) => { + const backend = createMemoryStore() + for (const [key, value] of Object.entries(seed)) backend.setItem(key, value) + return { backend, storage: createStorage(backend) } +} + +describe('loadTasks', () => { + it('returns an empty array for a missing key', () => { + const { storage } = setup() + expect(storage.loadTasks('2026-08-15')).toEqual([]) + }) + + it('returns stored tasks', () => { + const tasks = [{ id: 'a', text: 'buy milk', completed: false }] + const { storage } = setup({ 'negotium-tasks-2026-08-15': JSON.stringify(tasks) }) + expect(storage.loadTasks('2026-08-15')).toEqual(tasks) + }) + + it('returns an empty array for corrupt JSON instead of throwing', () => { + const { storage } = setup({ 'negotium-tasks-2026-08-15': '{not json' }) + expect(storage.loadTasks('2026-08-15')).toEqual([]) + }) + + it('returns an empty array when the stored value is not an array', () => { + const { storage } = setup({ 'negotium-tasks-2026-08-15': '{"a":1}' }) + expect(storage.loadTasks('2026-08-15')).toEqual([]) + }) + + it('survives a throwing backend', () => { + const backend = createMemoryStore() + backend.getItem = () => { + throw new Error('storage revoked') + } + const storage = createStorage(backend) + expect(storage.loadTasks('2026-08-15')).toEqual([]) + }) +}) + +describe('saveTasks', () => { + it('round-trips through the backend', () => { + const { storage } = setup() + const tasks = [{ id: 'a', text: 'x', completed: true }] + storage.saveTasks('2026-08-15', tasks) + expect(storage.loadTasks('2026-08-15')).toEqual(tasks) + }) + + it('swallows a throwing backend rather than propagating', () => { + const backend = createMemoryStore() + backend.setItem = () => { + throw new Error('QuotaExceededError') + } + const storage = createStorage(backend) + expect(() => storage.saveTasks('2026-08-15', [])).not.toThrow() + }) +}) + +describe('listTaskKeys', () => { + it('returns only date keys, with the prefix stripped', () => { + const { storage } = setup({ + 'negotium-tasks-2026-08-15': '[]', + 'negotium-tasks-2026-08-16': '[]', + 'negotium-theme': 'dark', + unrelated: 'x', + }) + expect(storage.listTaskKeys().sort()).toEqual(['2026-08-15', '2026-08-16']) + }) + + it('excludes legacy-format keys', () => { + const { storage } = setup({ + 'negotium-tasks-2026-08-15': '[]', + 'negotium-tasks-Sat Aug 15 2026': '[]', + }) + expect(storage.listTaskKeys()).toEqual(['2026-08-15']) + }) + + it('returns an empty array when enumeration throws', () => { + const backend = createMemoryStore() + backend.keys = () => { + throw new Error('nope') + } + expect(createStorage(backend).listTaskKeys()).toEqual([]) + }) +}) + +describe('removeTasks', () => { + it('deletes the entry', () => { + const { storage } = setup({ 'negotium-tasks-2026-08-15': '[]' }) + storage.removeTasks('2026-08-15') + expect(storage.listTaskKeys()).toEqual([]) + }) +}) + +describe('theme', () => { + it('returns null when unset', () => { + const { storage } = setup() + expect(storage.loadTheme()).toBe(null) + }) + + it('round-trips', () => { + const { storage } = setup() + storage.saveTheme('dark') + expect(storage.loadTheme()).toBe('dark') + }) +}) + +describe('migrateLegacyKeys', () => { + it('rewrites a legacy key to ISO form', () => { + const tasks = [{ id: 'a', text: 'legacy', completed: false }] + const { storage } = setup({ 'negotium-tasks-Sat Aug 15 2026': JSON.stringify(tasks) }) + storage.migrateLegacyKeys() + expect(storage.listTaskKeys()).toEqual(['2026-08-15']) + expect(storage.loadTasks('2026-08-15')).toEqual(tasks) + }) + + it('removes the legacy key once migrated', () => { + const { backend, storage } = setup({ 'negotium-tasks-Sat Aug 15 2026': '[]' }) + storage.migrateLegacyKeys() + expect(backend.getItem('negotium-tasks-Sat Aug 15 2026')).toBe(null) + }) + + it('merges legacy before existing when the ISO key is occupied', () => { + const legacy = [{ id: 'l', text: 'legacy', completed: false }] + const current = [{ id: 'c', text: 'current', completed: false }] + const { storage } = setup({ + 'negotium-tasks-Sat Aug 15 2026': JSON.stringify(legacy), + 'negotium-tasks-2026-08-15': JSON.stringify(current), + }) + storage.migrateLegacyKeys() + expect(storage.loadTasks('2026-08-15').map((t) => t.id)).toEqual(['l', 'c']) + }) + + it('leaves an unparseable key untouched rather than destroying it', () => { + const { storage, backend } = setup({ 'negotium-tasks-not-a-date': '[{"id":"x"}]' }) + storage.migrateLegacyKeys() + expect(backend.getItem('negotium-tasks-not-a-date')).toBe('[{"id":"x"}]') + }) + + it('is idempotent', () => { + const { storage } = setup({ 'negotium-tasks-Sat Aug 15 2026': '[{"id":"a"}]' }) + storage.migrateLegacyKeys() + storage.migrateLegacyKeys() + expect(storage.loadTasks('2026-08-15')).toEqual([{ id: 'a' }]) + }) + + it('migrates several legacy days independently', () => { + const { storage } = setup({ + 'negotium-tasks-Fri Aug 14 2026': '[{"id":"fri"}]', + 'negotium-tasks-Sat Aug 15 2026': '[{"id":"sat"}]', + }) + storage.migrateLegacyKeys() + expect(storage.listTaskKeys().sort()).toEqual(['2026-08-14', '2026-08-15']) + expect(storage.loadTasks('2026-08-14')).toEqual([{ id: 'fri' }]) + expect(storage.loadTasks('2026-08-15')).toEqual([{ id: 'sat' }]) + }) + + it('does nothing when there is nothing to migrate', () => { + const { storage } = setup({ 'negotium-tasks-2026-08-15': '[]' }) + storage.migrateLegacyKeys() + expect(storage.listTaskKeys()).toEqual(['2026-08-15']) + }) + + it('leaves the theme key alone', () => { + const { backend, storage } = setup({ 'negotium-theme': 'dark' }) + storage.migrateLegacyKeys() + expect(backend.getItem('negotium-theme')).toBe('dark') + }) +})