Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: threaded comments with replies and resolution (#48)' (#127) from feat/threaded-comments into main

scott 4cb9d512 aca045df

+450
+231
src/lib/comment-threads.ts
··· 1 + /** 2 + * Threaded Comments — comment thread management for documents. 3 + * 4 + * Pure logic module: thread creation, replies, resolution, sorting. 5 + * DOM rendering handled by the editor UI layer. 6 + */ 7 + 8 + export interface CommentEntry { 9 + id: string; 10 + author: string; 11 + text: string; 12 + createdAt: number; 13 + editedAt?: number; 14 + } 15 + 16 + export interface CommentThread { 17 + id: string; 18 + /** The inline mark/annotation ID in the document */ 19 + anchorId: string; 20 + /** Initial comment that started the thread */ 21 + root: CommentEntry; 22 + /** Reply chain */ 23 + replies: CommentEntry[]; 24 + /** Whether the thread has been resolved */ 25 + resolved: boolean; 26 + resolvedBy?: string; 27 + resolvedAt?: number; 28 + } 29 + 30 + export type ThreadSortOrder = 'newest' | 'oldest' | 'unresolved'; 31 + 32 + let _idCounter = 0; 33 + 34 + /** 35 + * Generate a unique ID for comments and threads. 36 + */ 37 + export function generateCommentId(): string { 38 + return `cmt-${Date.now()}-${++_idCounter}`; 39 + } 40 + 41 + /** 42 + * Reset ID counter (for testing). 43 + */ 44 + export function resetCommentIdCounter(): void { 45 + _idCounter = 0; 46 + } 47 + 48 + /** 49 + * Create a new comment thread anchored to a document selection. 50 + */ 51 + export function createThread( 52 + anchorId: string, 53 + author: string, 54 + text: string, 55 + now = Date.now(), 56 + ): CommentThread { 57 + const threadId = generateCommentId(); 58 + return { 59 + id: threadId, 60 + anchorId, 61 + root: { 62 + id: generateCommentId(), 63 + author, 64 + text, 65 + createdAt: now, 66 + }, 67 + replies: [], 68 + resolved: false, 69 + }; 70 + } 71 + 72 + /** 73 + * Add a reply to an existing thread. 74 + */ 75 + export function addReply( 76 + thread: CommentThread, 77 + author: string, 78 + text: string, 79 + now = Date.now(), 80 + ): CommentThread { 81 + const reply: CommentEntry = { 82 + id: generateCommentId(), 83 + author, 84 + text, 85 + createdAt: now, 86 + }; 87 + return { 88 + ...thread, 89 + replies: [...thread.replies, reply], 90 + }; 91 + } 92 + 93 + /** 94 + * Edit a comment (root or reply) within a thread. 95 + * Returns the updated thread, or the original if comment not found. 96 + */ 97 + export function editComment( 98 + thread: CommentThread, 99 + commentId: string, 100 + newText: string, 101 + now = Date.now(), 102 + ): CommentThread { 103 + if (thread.root.id === commentId) { 104 + return { 105 + ...thread, 106 + root: { ...thread.root, text: newText, editedAt: now }, 107 + }; 108 + } 109 + 110 + const replyIdx = thread.replies.findIndex(r => r.id === commentId); 111 + if (replyIdx === -1) return thread; 112 + 113 + const replies = [...thread.replies]; 114 + replies[replyIdx] = { ...replies[replyIdx], text: newText, editedAt: now }; 115 + return { ...thread, replies }; 116 + } 117 + 118 + /** 119 + * Delete a reply from a thread. Cannot delete the root comment. 120 + */ 121 + export function deleteReply( 122 + thread: CommentThread, 123 + replyId: string, 124 + ): CommentThread { 125 + return { 126 + ...thread, 127 + replies: thread.replies.filter(r => r.id !== replyId), 128 + }; 129 + } 130 + 131 + /** 132 + * Resolve a comment thread. 133 + */ 134 + export function resolveThread( 135 + thread: CommentThread, 136 + resolvedBy: string, 137 + now = Date.now(), 138 + ): CommentThread { 139 + return { 140 + ...thread, 141 + resolved: true, 142 + resolvedBy, 143 + resolvedAt: now, 144 + }; 145 + } 146 + 147 + /** 148 + * Reopen a resolved thread. 149 + */ 150 + export function reopenThread(thread: CommentThread): CommentThread { 151 + return { 152 + ...thread, 153 + resolved: false, 154 + resolvedBy: undefined, 155 + resolvedAt: undefined, 156 + }; 157 + } 158 + 159 + /** 160 + * Sort threads by the specified order. 161 + */ 162 + export function sortThreads( 163 + threads: CommentThread[], 164 + order: ThreadSortOrder, 165 + ): CommentThread[] { 166 + const sorted = [...threads]; 167 + 168 + switch (order) { 169 + case 'newest': 170 + sorted.sort((a, b) => b.root.createdAt - a.root.createdAt); 171 + break; 172 + case 'oldest': 173 + sorted.sort((a, b) => a.root.createdAt - b.root.createdAt); 174 + break; 175 + case 'unresolved': 176 + sorted.sort((a, b) => { 177 + // Unresolved first, then by newest 178 + if (a.resolved !== b.resolved) return a.resolved ? 1 : -1; 179 + return b.root.createdAt - a.root.createdAt; 180 + }); 181 + break; 182 + } 183 + 184 + return sorted; 185 + } 186 + 187 + /** 188 + * Get the total comment count in a thread (root + replies). 189 + */ 190 + export function threadCommentCount(thread: CommentThread): number { 191 + return 1 + thread.replies.length; 192 + } 193 + 194 + /** 195 + * Get the most recent activity timestamp in a thread. 196 + */ 197 + export function threadLastActivity(thread: CommentThread): number { 198 + if (thread.replies.length === 0) return thread.root.createdAt; 199 + return thread.replies[thread.replies.length - 1].createdAt; 200 + } 201 + 202 + /** 203 + * Filter threads by author. 204 + */ 205 + export function filterByAuthor( 206 + threads: CommentThread[], 207 + author: string, 208 + ): CommentThread[] { 209 + const lower = author.toLowerCase(); 210 + return threads.filter(t => 211 + t.root.author.toLowerCase() === lower || 212 + t.replies.some(r => r.author.toLowerCase() === lower), 213 + ); 214 + } 215 + 216 + /** 217 + * Count unresolved threads. 218 + */ 219 + export function unresolvedCount(threads: CommentThread[]): number { 220 + return threads.filter(t => !t.resolved).length; 221 + } 222 + 223 + /** 224 + * Find a thread by its anchor ID. 225 + */ 226 + export function findThreadByAnchor( 227 + threads: CommentThread[], 228 + anchorId: string, 229 + ): CommentThread | null { 230 + return threads.find(t => t.anchorId === anchorId) || null; 231 + }
+219
tests/comment-threads.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { 3 + createThread, 4 + addReply, 5 + editComment, 6 + deleteReply, 7 + resolveThread, 8 + reopenThread, 9 + sortThreads, 10 + threadCommentCount, 11 + threadLastActivity, 12 + filterByAuthor, 13 + unresolvedCount, 14 + findThreadByAnchor, 15 + resetCommentIdCounter, 16 + type CommentThread, 17 + } from '../src/lib/comment-threads.js'; 18 + 19 + describe('Comment Threads', () => { 20 + beforeEach(() => { 21 + resetCommentIdCounter(); 22 + }); 23 + 24 + describe('createThread', () => { 25 + it('creates a thread with root comment', () => { 26 + const thread = createThread('anchor-1', 'Alice', 'Great point!', 1000); 27 + expect(thread.anchorId).toBe('anchor-1'); 28 + expect(thread.root.author).toBe('Alice'); 29 + expect(thread.root.text).toBe('Great point!'); 30 + expect(thread.root.createdAt).toBe(1000); 31 + expect(thread.replies).toEqual([]); 32 + expect(thread.resolved).toBe(false); 33 + }); 34 + 35 + it('generates unique IDs', () => { 36 + const t1 = createThread('a1', 'Alice', 'First', 1000); 37 + const t2 = createThread('a2', 'Bob', 'Second', 2000); 38 + expect(t1.id).not.toBe(t2.id); 39 + expect(t1.root.id).not.toBe(t2.root.id); 40 + }); 41 + }); 42 + 43 + describe('addReply', () => { 44 + it('adds a reply to a thread', () => { 45 + const thread = createThread('a1', 'Alice', 'Question?', 1000); 46 + const updated = addReply(thread, 'Bob', 'Answer!', 2000); 47 + expect(updated.replies).toHaveLength(1); 48 + expect(updated.replies[0].author).toBe('Bob'); 49 + expect(updated.replies[0].text).toBe('Answer!'); 50 + }); 51 + 52 + it('preserves existing replies', () => { 53 + let thread = createThread('a1', 'Alice', 'Start', 1000); 54 + thread = addReply(thread, 'Bob', 'Reply 1', 2000); 55 + thread = addReply(thread, 'Carol', 'Reply 2', 3000); 56 + expect(thread.replies).toHaveLength(2); 57 + expect(thread.replies[0].author).toBe('Bob'); 58 + expect(thread.replies[1].author).toBe('Carol'); 59 + }); 60 + }); 61 + 62 + describe('editComment', () => { 63 + it('edits root comment', () => { 64 + const thread = createThread('a1', 'Alice', 'Original', 1000); 65 + const updated = editComment(thread, thread.root.id, 'Edited', 2000); 66 + expect(updated.root.text).toBe('Edited'); 67 + expect(updated.root.editedAt).toBe(2000); 68 + }); 69 + 70 + it('edits a reply', () => { 71 + let thread = createThread('a1', 'Alice', 'Root', 1000); 72 + thread = addReply(thread, 'Bob', 'Original reply', 2000); 73 + const replyId = thread.replies[0].id; 74 + const updated = editComment(thread, replyId, 'Fixed reply', 3000); 75 + expect(updated.replies[0].text).toBe('Fixed reply'); 76 + expect(updated.replies[0].editedAt).toBe(3000); 77 + }); 78 + 79 + it('returns unchanged thread for unknown comment id', () => { 80 + const thread = createThread('a1', 'Alice', 'Text', 1000); 81 + const updated = editComment(thread, 'nonexistent', 'New', 2000); 82 + expect(updated).toEqual(thread); 83 + }); 84 + }); 85 + 86 + describe('deleteReply', () => { 87 + it('removes a reply', () => { 88 + let thread = createThread('a1', 'Alice', 'Root', 1000); 89 + thread = addReply(thread, 'Bob', 'To delete', 2000); 90 + thread = addReply(thread, 'Carol', 'To keep', 3000); 91 + const replyId = thread.replies[0].id; 92 + const updated = deleteReply(thread, replyId); 93 + expect(updated.replies).toHaveLength(1); 94 + expect(updated.replies[0].author).toBe('Carol'); 95 + }); 96 + 97 + it('ignores unknown reply id', () => { 98 + const thread = createThread('a1', 'Alice', 'Root', 1000); 99 + const updated = deleteReply(thread, 'nonexistent'); 100 + expect(updated.replies).toEqual([]); 101 + }); 102 + }); 103 + 104 + describe('resolveThread / reopenThread', () => { 105 + it('resolves a thread', () => { 106 + const thread = createThread('a1', 'Alice', 'Issue', 1000); 107 + const resolved = resolveThread(thread, 'Bob', 5000); 108 + expect(resolved.resolved).toBe(true); 109 + expect(resolved.resolvedBy).toBe('Bob'); 110 + expect(resolved.resolvedAt).toBe(5000); 111 + }); 112 + 113 + it('reopens a resolved thread', () => { 114 + const thread = createThread('a1', 'Alice', 'Issue', 1000); 115 + const resolved = resolveThread(thread, 'Bob', 5000); 116 + const reopened = reopenThread(resolved); 117 + expect(reopened.resolved).toBe(false); 118 + expect(reopened.resolvedBy).toBeUndefined(); 119 + expect(reopened.resolvedAt).toBeUndefined(); 120 + }); 121 + }); 122 + 123 + describe('sortThreads', () => { 124 + const threads: CommentThread[] = [ 125 + createThread('a1', 'Alice', 'Old', 1000), 126 + createThread('a2', 'Bob', 'New', 3000), 127 + createThread('a3', 'Carol', 'Mid', 2000), 128 + ]; 129 + 130 + it('sorts newest first', () => { 131 + const sorted = sortThreads(threads, 'newest'); 132 + expect(sorted[0].root.createdAt).toBe(3000); 133 + expect(sorted[2].root.createdAt).toBe(1000); 134 + }); 135 + 136 + it('sorts oldest first', () => { 137 + const sorted = sortThreads(threads, 'oldest'); 138 + expect(sorted[0].root.createdAt).toBe(1000); 139 + expect(sorted[2].root.createdAt).toBe(3000); 140 + }); 141 + 142 + it('sorts unresolved first', () => { 143 + const withResolved = [ 144 + ...threads, 145 + resolveThread(createThread('a4', 'Dave', 'Resolved', 4000), 'Eve', 5000), 146 + ]; 147 + const sorted = sortThreads(withResolved, 'unresolved'); 148 + expect(sorted[0].resolved).toBe(false); 149 + expect(sorted[sorted.length - 1].resolved).toBe(true); 150 + }); 151 + }); 152 + 153 + describe('threadCommentCount', () => { 154 + it('counts root + replies', () => { 155 + let thread = createThread('a1', 'Alice', 'Root', 1000); 156 + expect(threadCommentCount(thread)).toBe(1); 157 + thread = addReply(thread, 'Bob', 'Reply', 2000); 158 + expect(threadCommentCount(thread)).toBe(2); 159 + }); 160 + }); 161 + 162 + describe('threadLastActivity', () => { 163 + it('returns root time when no replies', () => { 164 + const thread = createThread('a1', 'Alice', 'Root', 1000); 165 + expect(threadLastActivity(thread)).toBe(1000); 166 + }); 167 + 168 + it('returns last reply time', () => { 169 + let thread = createThread('a1', 'Alice', 'Root', 1000); 170 + thread = addReply(thread, 'Bob', 'Reply', 5000); 171 + expect(threadLastActivity(thread)).toBe(5000); 172 + }); 173 + }); 174 + 175 + describe('filterByAuthor', () => { 176 + it('finds threads where author participated', () => { 177 + const t1 = createThread('a1', 'Alice', 'Thread 1', 1000); 178 + const t2 = addReply( 179 + createThread('a2', 'Bob', 'Thread 2', 2000), 180 + 'Alice', 'Reply', 3000, 181 + ); 182 + const t3 = createThread('a3', 'Carol', 'Thread 3', 4000); 183 + const filtered = filterByAuthor([t1, t2, t3], 'Alice'); 184 + expect(filtered).toHaveLength(2); 185 + }); 186 + 187 + it('is case-insensitive', () => { 188 + const thread = createThread('a1', 'Alice', 'Thread', 1000); 189 + expect(filterByAuthor([thread], 'alice')).toHaveLength(1); 190 + }); 191 + }); 192 + 193 + describe('unresolvedCount', () => { 194 + it('counts unresolved threads', () => { 195 + const threads = [ 196 + createThread('a1', 'Alice', 'Open', 1000), 197 + resolveThread(createThread('a2', 'Bob', 'Closed', 2000), 'Eve', 3000), 198 + createThread('a3', 'Carol', 'Open', 4000), 199 + ]; 200 + expect(unresolvedCount(threads)).toBe(2); 201 + }); 202 + }); 203 + 204 + describe('findThreadByAnchor', () => { 205 + it('finds thread by anchor ID', () => { 206 + const threads = [ 207 + createThread('anchor-1', 'Alice', 'A', 1000), 208 + createThread('anchor-2', 'Bob', 'B', 2000), 209 + ]; 210 + const found = findThreadByAnchor(threads, 'anchor-2'); 211 + expect(found).not.toBeNull(); 212 + expect(found!.root.author).toBe('Bob'); 213 + }); 214 + 215 + it('returns null for unknown anchor', () => { 216 + expect(findThreadByAnchor([], 'unknown')).toBeNull(); 217 + }); 218 + }); 219 + });