From 9fb8acc71996d621b57073571b522c3ddc5f928c Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Sat, 21 Mar 2026 23:26:00 +0100 Subject: [PATCH] feat: checklist utility module with parentId and nesting logic Extract checklist logic into testable utility module: - ChecklistItem type with parentId field for single-level nesting - parseChecklist/serializeChecklist with 2-space prefix for children - toggleItemWithCascade: parent check/uncheck cascades to children - indentItem/outdentItem with sibling adoption rules - 21 unit tests covering parse, serialize, toggle, indent, outdent Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/utils/checklist.test.ts | 207 ++++++++++++++++++++++++++++++++ src/lib/utils/checklist.ts | 134 +++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 src/lib/utils/checklist.test.ts create mode 100644 src/lib/utils/checklist.ts diff --git a/src/lib/utils/checklist.test.ts b/src/lib/utils/checklist.test.ts new file mode 100644 index 0000000..c235827 --- /dev/null +++ b/src/lib/utils/checklist.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from 'vitest'; +import { parseChecklist, serializeChecklist, toggleItemWithCascade, indentItem, outdentItem, type ChecklistItem } from '$lib/utils/checklist.js'; + +describe('parseChecklist', () => { + it('parses flat checklist items', () => { + const items = parseChecklist('- [ ] Milk\n- [x] Eggs'); + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ text: 'Milk', checked: false, parentId: null }); + expect(items[1]).toMatchObject({ text: 'Eggs', checked: true, parentId: null }); + }); + + it('parses nested children with 2-space prefix', () => { + const items = parseChecklist('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs'); + expect(items).toHaveLength(3); + expect(items[0]).toMatchObject({ text: 'Groceries', checked: false, parentId: null }); + expect(items[1]).toMatchObject({ text: 'Milk', checked: false, parentId: items[0].id }); + expect(items[2]).toMatchObject({ text: 'Eggs', checked: true, parentId: items[0].id }); + }); + + it('treats orphaned children as top-level', () => { + const items = parseChecklist(' - [ ] Orphan\n- [ ] Parent'); + expect(items[0]).toMatchObject({ text: 'Orphan', parentId: null }); + }); + + it('returns one empty item for blank content', () => { + const items = parseChecklist(''); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ text: '', checked: false, parentId: null }); + }); + + it('handles mixed top-level and nested items', () => { + const content = '- [ ] A\n - [ ] A1\n - [ ] A2\n- [ ] B\n - [x] B1'; + const items = parseChecklist(content); + expect(items).toHaveLength(5); + expect(items[1].parentId).toBe(items[0].id); + expect(items[2].parentId).toBe(items[0].id); + expect(items[4].parentId).toBe(items[3].id); + }); +}); + +describe('serializeChecklist', () => { + it('serializes flat items', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'Milk', checked: false, parentId: null }, + { id: '2', text: 'Eggs', checked: true, parentId: null } + ]; + expect(serializeChecklist(items)).toBe('- [ ] Milk\n- [x] Eggs'); + }); + + it('serializes nested items with 2-space prefix', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'Groceries', checked: false, parentId: null }, + { id: '2', text: 'Milk', checked: false, parentId: '1' }, + { id: '3', text: 'Eggs', checked: true, parentId: '1' } + ]; + expect(serializeChecklist(items)).toBe('- [ ] Groceries\n - [ ] Milk\n - [x] Eggs'); + }); + + it('groups children under parents regardless of array order', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'A', checked: false, parentId: null }, + { id: '3', text: 'B', checked: false, parentId: null }, + { id: '2', text: 'A1', checked: false, parentId: '1' } + ]; + const result = serializeChecklist(items); + expect(result).toBe('- [ ] A\n - [ ] A1\n- [ ] B'); + }); + + it('round-trips parse then serialize', () => { + const original = '- [ ] Groceries\n - [ ] Milk\n - [x] Eggs\n- [ ] Clean'; + const items = parseChecklist(original); + expect(serializeChecklist(items)).toBe(original); + }); +}); + +describe('toggleItemWithCascade', () => { + it('checking a parent checks all children', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'Parent', checked: false, parentId: null }, + { id: '2', text: 'Child A', checked: false, parentId: '1' }, + { id: '3', text: 'Child B', checked: false, parentId: '1' } + ]; + const result = toggleItemWithCascade('1', items); + expect(result.every((i) => i.checked)).toBe(true); + }); + + it('unchecking a parent unchecks all children', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'Parent', checked: true, parentId: null }, + { id: '2', text: 'Child A', checked: true, parentId: '1' }, + { id: '3', text: 'Child B', checked: true, parentId: '1' } + ]; + const result = toggleItemWithCascade('1', items); + expect(result.every((i) => !i.checked)).toBe(true); + }); + + it('checking a child does not affect parent', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'Parent', checked: false, parentId: null }, + { id: '2', text: 'Child', checked: false, parentId: '1' } + ]; + const result = toggleItemWithCascade('2', items); + expect(result.find((i) => i.id === '1')!.checked).toBe(false); + expect(result.find((i) => i.id === '2')!.checked).toBe(true); + }); + + it('unchecking a child with deleted parent orphans it', () => { + const items: ChecklistItem[] = [ + { id: '2', text: 'Orphan', checked: true, parentId: 'deleted-id' } + ]; + const result = toggleItemWithCascade('2', items); + expect(result[0].checked).toBe(false); + expect(result[0].parentId).toBeNull(); + }); +}); + +describe('indentItem', () => { + it('sets parentId to nearest preceding top-level item', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'A', checked: false, parentId: null }, + { id: '2', text: 'B', checked: false, parentId: null } + ]; + const active = items.filter(i => !i.checked); + const result = indentItem('2', items, active); + expect(result.find((i) => i.id === '2')!.parentId).toBe('1'); + }); + + it('no-op if already a child', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'A', checked: false, parentId: null }, + { id: '2', text: 'B', checked: false, parentId: '1' } + ]; + const active = items.filter(i => !i.checked); + const result = indentItem('2', items, active); + expect(result).toEqual(items); + }); + + it('no-op if first item (no parent above)', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'A', checked: false, parentId: null } + ]; + const active = items.filter(i => !i.checked); + const result = indentItem('1', items, active); + expect(result[0].parentId).toBeNull(); + }); + + it('reparents children to grandparent when indenting a parent', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'A', checked: false, parentId: null }, + { id: '2', text: 'B', checked: false, parentId: null }, + { id: '3', text: 'C', checked: false, parentId: '2' }, + { id: '4', text: 'D', checked: false, parentId: '2' } + ]; + const active = items.filter(i => !i.checked); + const result = indentItem('2', items, active); + expect(result.find((i) => i.id === '2')!.parentId).toBe('1'); + expect(result.find((i) => i.id === '3')!.parentId).toBe('1'); + expect(result.find((i) => i.id === '4')!.parentId).toBe('1'); + }); +}); + +describe('outdentItem', () => { + it('sets parentId to null', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'A', checked: false, parentId: null }, + { id: '2', text: 'B', checked: false, parentId: '1' } + ]; + const result = outdentItem('2', items); + expect(result.find((i) => i.id === '2')!.parentId).toBeNull(); + }); + + it('no-op if already top-level', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'A', checked: false, parentId: null } + ]; + const result = outdentItem('1', items); + expect(result[0].parentId).toBeNull(); + }); + + it('adopts consecutive siblings below with same parentId', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'A', checked: false, parentId: null }, + { id: '2', text: 'B', checked: false, parentId: '1' }, + { id: '3', text: 'C', checked: false, parentId: '1' }, + { id: '4', text: 'D', checked: false, parentId: '1' }, + { id: '5', text: 'E', checked: false, parentId: null } + ]; + const result = outdentItem('2', items); + expect(result.find((i) => i.id === '2')!.parentId).toBeNull(); + expect(result.find((i) => i.id === '3')!.parentId).toBe('2'); + expect(result.find((i) => i.id === '4')!.parentId).toBe('2'); + expect(result.find((i) => i.id === '5')!.parentId).toBeNull(); + }); + + it('stops adoption at first non-sibling', () => { + const items: ChecklistItem[] = [ + { id: '1', text: 'A', checked: false, parentId: null }, + { id: '2', text: 'B', checked: false, parentId: '1' }, + { id: '5', text: 'E', checked: false, parentId: null }, + { id: '3', text: 'C', checked: false, parentId: '1' } + ]; + const result = outdentItem('2', items); + expect(result.find((i) => i.id === '2')!.parentId).toBeNull(); + expect(result.find((i) => i.id === '5')!.parentId).toBeNull(); + expect(result.find((i) => i.id === '3')!.parentId).toBe('1'); + }); +}); diff --git a/src/lib/utils/checklist.ts b/src/lib/utils/checklist.ts new file mode 100644 index 0000000..73d68af --- /dev/null +++ b/src/lib/utils/checklist.ts @@ -0,0 +1,134 @@ +export interface ChecklistItem { + id: string; + text: string; + checked: boolean; + parentId: string | null; +} + +export function generateId(): string { + return crypto.randomUUID(); +} + +export function isChild(item: ChecklistItem): boolean { + return item.parentId !== null; +} + +export function getChildren(parentId: string, items: ChecklistItem[]): ChecklistItem[] { + return items.filter((i) => i.parentId === parentId); +} + +export function getParent(item: ChecklistItem, items: ChecklistItem[]): ChecklistItem | undefined { + return item.parentId ? items.find((i) => i.id === item.parentId) : undefined; +} + +export function toggleItemWithCascade(id: string, items: ChecklistItem[]): ChecklistItem[] { + const target = items.find((i) => i.id === id)!; + const newChecked = !target.checked; + + let result = items.map((item) => { + if (item.id === id) return { ...item, checked: newChecked }; + if (item.parentId === id) return { ...item, checked: newChecked }; + return item; + }); + + // When unchecking a child from done: if parent no longer exists, orphan it + if (!newChecked && target.parentId) { + const parentExists = result.some((i) => i.id === target.parentId); + if (!parentExists) { + result = result.map((i) => (i.id === id ? { ...i, parentId: null } : i)); + } + } + + return result; +} + +export function indentItem(id: string, items: ChecklistItem[], activeItems: ChecklistItem[]): ChecklistItem[] { + const item = items.find((i) => i.id === id); + if (!item || item.parentId !== null) return items; + + const activeIndex = activeItems.findIndex((i) => i.id === id); + let newParentId: string | null = null; + for (let i = activeIndex - 1; i >= 0; i--) { + if (activeItems[i].parentId === null) { + newParentId = activeItems[i].id; + break; + } + } + if (!newParentId) return items; + + return items.map((i) => { + if (i.id === id) return { ...i, parentId: newParentId }; + if (i.parentId === id) return { ...i, parentId: newParentId }; + return i; + }); +} + +export function outdentItem(id: string, items: ChecklistItem[]): ChecklistItem[] { + const index = items.findIndex((i) => i.id === id); + const item = items[index]; + if (!item || item.parentId === null) return items; + + const originalParentId = item.parentId; + + const adoptees: string[] = []; + for (let i = index + 1; i < items.length; i++) { + if (items[i].parentId === originalParentId) { + adoptees.push(items[i].id); + } else { + break; + } + } + + return items.map((i) => { + if (i.id === id) return { ...i, parentId: null }; + if (adoptees.includes(i.id)) return { ...i, parentId: id }; + return i; + }); +} + +export function parseChecklist(text: string): ChecklistItem[] { + if (!text.trim()) return [{ id: generateId(), text: '', checked: false, parentId: null }]; + + let lastTopLevelId: string | null = null; + return text.split('\n').map((line) => { + if (line.startsWith(' - [x] ')) { + return { id: generateId(), text: line.slice(8), checked: true, parentId: lastTopLevelId }; + } + if (line.startsWith(' - [ ] ')) { + return { id: generateId(), text: line.slice(8), checked: false, parentId: lastTopLevelId }; + } + const item: ChecklistItem = { id: generateId(), text: '', checked: false, parentId: null }; + if (line.startsWith('- [x] ')) { + item.text = line.slice(6); + item.checked = true; + } else if (line.startsWith('- [ ] ')) { + item.text = line.slice(6); + } else { + item.text = line; + } + lastTopLevelId = item.id; + return item; + }); +} + +export function serializeChecklist(items: ChecklistItem[]): string { + const topLevel = items.filter((i) => i.parentId === null); + const childMap = new Map(); + for (const item of items) { + if (item.parentId) { + const siblings = childMap.get(item.parentId) ?? []; + siblings.push(item); + childMap.set(item.parentId, siblings); + } + } + + const lines: string[] = []; + for (const parent of topLevel) { + lines.push(`- [${parent.checked ? 'x' : ' '}] ${parent.text}`); + const children = childMap.get(parent.id) ?? []; + for (const child of children) { + lines.push(` - [${child.checked ? 'x' : ' '}] ${child.text}`); + } + } + return lines.join('\n'); +}