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: activity log, cursor presence, timeline view (#124, #121, #76)' (#135) from feat/activity-log into main

scott f7faac99 b9554a50

+1278
+207
src/lib/activity-log.ts
··· 1 + /** 2 + * Activity Log — encrypted audit trail for document actions. 3 + * 4 + * Pure logic module: event creation, filtering, pagination, aggregation. 5 + * Storage and encryption handled by the collaboration layer. 6 + */ 7 + 8 + export type ActivityAction = 9 + | 'create' 10 + | 'edit' 11 + | 'delete' 12 + | 'rename' 13 + | 'share' 14 + | 'unshare' 15 + | 'comment' 16 + | 'resolve' 17 + | 'restore' 18 + | 'move' 19 + | 'view'; 20 + 21 + export interface ActivityEntry { 22 + id: string; 23 + timestamp: number; 24 + userId: string; 25 + userName: string; 26 + action: ActivityAction; 27 + documentId: string; 28 + documentName: string; 29 + /** Optional details (e.g., old name for rename, comment text) */ 30 + details: string; 31 + } 32 + 33 + export interface ActivityLogState { 34 + entries: ActivityEntry[]; 35 + /** Max entries to retain in memory */ 36 + maxEntries: number; 37 + } 38 + 39 + let _entryCounter = 0; 40 + 41 + /** 42 + * Create initial activity log state. 43 + */ 44 + export function createActivityLog(maxEntries = 1000): ActivityLogState { 45 + return { entries: [], maxEntries }; 46 + } 47 + 48 + /** 49 + * Record a new activity entry. 50 + */ 51 + export function recordActivity( 52 + state: ActivityLogState, 53 + userId: string, 54 + userName: string, 55 + action: ActivityAction, 56 + documentId: string, 57 + documentName: string, 58 + details = '', 59 + ): ActivityLogState { 60 + const entry: ActivityEntry = { 61 + id: `act-${Date.now()}-${++_entryCounter}`, 62 + timestamp: Date.now(), 63 + userId, 64 + userName, 65 + action, 66 + documentId, 67 + documentName, 68 + details, 69 + }; 70 + 71 + const entries = [entry, ...state.entries]; 72 + // Trim to maxEntries 73 + if (entries.length > state.maxEntries) { 74 + entries.length = state.maxEntries; 75 + } 76 + 77 + return { ...state, entries }; 78 + } 79 + 80 + /** 81 + * Filter entries by action type. 82 + */ 83 + export function filterByAction( 84 + state: ActivityLogState, 85 + action: ActivityAction, 86 + ): ActivityEntry[] { 87 + return state.entries.filter(e => e.action === action); 88 + } 89 + 90 + /** 91 + * Filter entries by user. 92 + */ 93 + export function filterByUser( 94 + state: ActivityLogState, 95 + userId: string, 96 + ): ActivityEntry[] { 97 + return state.entries.filter(e => e.userId === userId); 98 + } 99 + 100 + /** 101 + * Filter entries by document. 102 + */ 103 + export function filterByDocument( 104 + state: ActivityLogState, 105 + documentId: string, 106 + ): ActivityEntry[] { 107 + return state.entries.filter(e => e.documentId === documentId); 108 + } 109 + 110 + /** 111 + * Filter entries within a time range. 112 + */ 113 + export function filterByTimeRange( 114 + state: ActivityLogState, 115 + start: number, 116 + end: number, 117 + ): ActivityEntry[] { 118 + return state.entries.filter(e => e.timestamp >= start && e.timestamp <= end); 119 + } 120 + 121 + /** 122 + * Paginate entries. 123 + */ 124 + export function paginateEntries( 125 + entries: ActivityEntry[], 126 + page: number, 127 + pageSize: number, 128 + ): ActivityEntry[] { 129 + const offset = page * pageSize; 130 + return entries.slice(offset, offset + pageSize); 131 + } 132 + 133 + /** 134 + * Search entries by text (matches document name, user name, or details). 135 + */ 136 + export function searchEntries( 137 + state: ActivityLogState, 138 + query: string, 139 + ): ActivityEntry[] { 140 + if (!query) return state.entries; 141 + 142 + const lower = query.toLowerCase(); 143 + return state.entries.filter( 144 + e => 145 + e.documentName.toLowerCase().includes(lower) || 146 + e.userName.toLowerCase().includes(lower) || 147 + e.details.toLowerCase().includes(lower), 148 + ); 149 + } 150 + 151 + /** 152 + * Count entries grouped by action type. 153 + */ 154 + export function countByAction( 155 + state: ActivityLogState, 156 + ): Record<string, number> { 157 + const counts: Record<string, number> = {}; 158 + for (const e of state.entries) { 159 + counts[e.action] = (counts[e.action] || 0) + 1; 160 + } 161 + return counts; 162 + } 163 + 164 + /** 165 + * Count entries grouped by user. 166 + */ 167 + export function countByUser( 168 + state: ActivityLogState, 169 + ): Record<string, number> { 170 + const counts: Record<string, number> = {}; 171 + for (const e of state.entries) { 172 + counts[e.userId] = (counts[e.userId] || 0) + 1; 173 + } 174 + return counts; 175 + } 176 + 177 + /** 178 + * Get the most recent activity for a document. 179 + */ 180 + export function lastActivityForDocument( 181 + state: ActivityLogState, 182 + documentId: string, 183 + ): ActivityEntry | null { 184 + return state.entries.find(e => e.documentId === documentId) ?? null; 185 + } 186 + 187 + /** 188 + * Get unique document IDs that have activity. 189 + */ 190 + export function activeDocuments(state: ActivityLogState): string[] { 191 + const seen = new Set<string>(); 192 + const result: string[] = []; 193 + for (const e of state.entries) { 194 + if (!seen.has(e.documentId)) { 195 + seen.add(e.documentId); 196 + result.push(e.documentId); 197 + } 198 + } 199 + return result; 200 + } 201 + 202 + /** 203 + * Clear all entries. 204 + */ 205 + export function clearActivityLog(state: ActivityLogState): ActivityLogState { 206 + return { ...state, entries: [] }; 207 + }
+185
src/lib/cursor-presence.ts
··· 1 + /** 2 + * Cursor Presence Overlay — collaborative cursor/selection display. 3 + * 4 + * Pure logic module: cursor state management, color assignment, staleness detection. 5 + * DOM rendering (overlays, labels) handled in the editor UI layer. 6 + */ 7 + 8 + export interface CursorPosition { 9 + /** For docs: character offset. For sheets: cell ID like "B3" */ 10 + anchor: string; 11 + /** Selection end (same as anchor if no selection) */ 12 + head: string; 13 + } 14 + 15 + export interface UserPresence { 16 + userId: string; 17 + userName: string; 18 + color: string; 19 + cursor: CursorPosition | null; 20 + lastSeen: number; 21 + } 22 + 23 + export interface PresenceState { 24 + /** Current user's ID (excluded from display) */ 25 + localUserId: string; 26 + /** Remote user presences keyed by userId */ 27 + users: Map<string, UserPresence>; 28 + /** Milliseconds before a cursor is considered stale */ 29 + staleThreshold: number; 30 + } 31 + 32 + /** Default palette for assigning user colors */ 33 + const PRESENCE_COLORS = [ 34 + '#e74c3c', '#3498db', '#2ecc71', '#f39c12', 35 + '#9b59b6', '#1abc9c', '#e67e22', '#e84393', 36 + ]; 37 + 38 + /** 39 + * Create initial presence state. 40 + */ 41 + export function createPresenceState( 42 + localUserId: string, 43 + staleThreshold = 30000, 44 + ): PresenceState { 45 + return { 46 + localUserId, 47 + users: new Map(), 48 + staleThreshold, 49 + }; 50 + } 51 + 52 + /** 53 + * Assign a color to a user based on their index. 54 + */ 55 + export function assignColor(index: number): string { 56 + return PRESENCE_COLORS[index % PRESENCE_COLORS.length]; 57 + } 58 + 59 + /** 60 + * Update a remote user's cursor position. 61 + */ 62 + export function updateCursor( 63 + state: PresenceState, 64 + userId: string, 65 + userName: string, 66 + cursor: CursorPosition, 67 + color?: string, 68 + ): PresenceState { 69 + if (userId === state.localUserId) return state; 70 + 71 + const users = new Map(state.users); 72 + const existing = users.get(userId); 73 + users.set(userId, { 74 + userId, 75 + userName, 76 + color: color ?? existing?.color ?? assignColor(users.size), 77 + cursor, 78 + lastSeen: Date.now(), 79 + }); 80 + 81 + return { ...state, users }; 82 + } 83 + 84 + /** 85 + * Remove a user's presence (e.g., on disconnect). 86 + */ 87 + export function removeUser( 88 + state: PresenceState, 89 + userId: string, 90 + ): PresenceState { 91 + if (!state.users.has(userId)) return state; 92 + const users = new Map(state.users); 93 + users.delete(userId); 94 + return { ...state, users }; 95 + } 96 + 97 + /** 98 + * Check if a user's cursor is stale. 99 + */ 100 + export function isCursorStale( 101 + presence: UserPresence, 102 + staleThreshold: number, 103 + now = Date.now(), 104 + ): boolean { 105 + return now - presence.lastSeen > staleThreshold; 106 + } 107 + 108 + /** 109 + * Get all active (non-stale) remote cursors. 110 + */ 111 + export function getActiveCursors( 112 + state: PresenceState, 113 + now = Date.now(), 114 + ): UserPresence[] { 115 + const result: UserPresence[] = []; 116 + for (const presence of state.users.values()) { 117 + if (presence.cursor && !isCursorStale(presence, state.staleThreshold, now)) { 118 + result.push(presence); 119 + } 120 + } 121 + return result; 122 + } 123 + 124 + /** 125 + * Get all stale cursors (for cleanup or dimming). 126 + */ 127 + export function getStaleCursors( 128 + state: PresenceState, 129 + now = Date.now(), 130 + ): UserPresence[] { 131 + const result: UserPresence[] = []; 132 + for (const presence of state.users.values()) { 133 + if (isCursorStale(presence, state.staleThreshold, now)) { 134 + result.push(presence); 135 + } 136 + } 137 + return result; 138 + } 139 + 140 + /** 141 + * Remove all stale users from state. 142 + */ 143 + export function pruneStaleUsers( 144 + state: PresenceState, 145 + now = Date.now(), 146 + ): PresenceState { 147 + const users = new Map<string, UserPresence>(); 148 + for (const [id, presence] of state.users) { 149 + if (!isCursorStale(presence, state.staleThreshold, now)) { 150 + users.set(id, presence); 151 + } 152 + } 153 + if (users.size === state.users.size) return state; 154 + return { ...state, users }; 155 + } 156 + 157 + /** 158 + * Count active remote users. 159 + */ 160 + export function activeUserCount( 161 + state: PresenceState, 162 + now = Date.now(), 163 + ): number { 164 + return getActiveCursors(state, now).length; 165 + } 166 + 167 + /** 168 + * Check if a specific cell/position has a remote cursor. 169 + */ 170 + export function cursorsAtPosition( 171 + state: PresenceState, 172 + position: string, 173 + now = Date.now(), 174 + ): UserPresence[] { 175 + return getActiveCursors(state, now).filter( 176 + p => p.cursor!.anchor === position || p.cursor!.head === position, 177 + ); 178 + } 179 + 180 + /** 181 + * Build cursor label text (shown next to cursor overlay). 182 + */ 183 + export function cursorLabel(presence: UserPresence): string { 184 + return presence.userName; 185 + }
+224
src/sheets/timeline-view.ts
··· 1 + /** 2 + * Timeline / Gantt View — date-range visualization for sheets. 3 + * 4 + * Pure logic module: task extraction, date math, layout computation. 5 + * Canvas/DOM rendering handled in the sheets UI layer. 6 + */ 7 + 8 + export interface TimelineTask { 9 + rowIndex: number; 10 + label: string; 11 + startDate: Date; 12 + endDate: Date; 13 + /** Progress 0-100 */ 14 + progress: number; 15 + /** Optional group/category for swimlanes */ 16 + group: string; 17 + } 18 + 19 + export interface TimelineConfig { 20 + labelColumn: number; 21 + startDateColumn: number; 22 + endDateColumn: number; 23 + progressColumn: number | null; 24 + groupColumn: number | null; 25 + } 26 + 27 + export interface TimelineRange { 28 + start: Date; 29 + end: Date; 30 + totalDays: number; 31 + } 32 + 33 + /** 34 + * Parse a date value from a cell (string or Date). 35 + */ 36 + export function parseTimelineDate(value: unknown): Date | null { 37 + if (!value) return null; 38 + if (value instanceof Date) { 39 + return isNaN(value.getTime()) ? null : value; 40 + } 41 + if (typeof value === 'string') { 42 + const d = new Date(value); 43 + return isNaN(d.getTime()) ? null : d; 44 + } 45 + if (typeof value === 'number') { 46 + const d = new Date(value); 47 + return isNaN(d.getTime()) ? null : d; 48 + } 49 + return null; 50 + } 51 + 52 + /** 53 + * Extract timeline tasks from sheet data. 54 + */ 55 + export function extractTasks( 56 + rows: Map<string, unknown>[], 57 + config: TimelineConfig, 58 + colToLetter: (col: number) => string, 59 + startRow = 1, 60 + ): TimelineTask[] { 61 + const tasks: TimelineTask[] = []; 62 + 63 + for (let i = 0; i < rows.length; i++) { 64 + const row = rows[i]; 65 + const rowIndex = startRow + i; 66 + const labelKey = `${colToLetter(config.labelColumn)}${rowIndex}`; 67 + const startKey = `${colToLetter(config.startDateColumn)}${rowIndex}`; 68 + const endKey = `${colToLetter(config.endDateColumn)}${rowIndex}`; 69 + 70 + const label = String(row.get(labelKey) ?? ''); 71 + const startDate = parseTimelineDate(row.get(startKey)); 72 + const endDate = parseTimelineDate(row.get(endKey)); 73 + 74 + if (!startDate || !endDate || !label) continue; 75 + 76 + let progress = 0; 77 + if (config.progressColumn !== null) { 78 + const progKey = `${colToLetter(config.progressColumn)}${rowIndex}`; 79 + const progVal = row.get(progKey); 80 + if (typeof progVal === 'number') { 81 + progress = Math.max(0, Math.min(100, progVal)); 82 + } 83 + } 84 + 85 + let group = ''; 86 + if (config.groupColumn !== null) { 87 + const groupKey = `${colToLetter(config.groupColumn)}${rowIndex}`; 88 + group = String(row.get(groupKey) ?? ''); 89 + } 90 + 91 + tasks.push({ rowIndex, label, startDate, endDate, progress, group }); 92 + } 93 + 94 + return tasks; 95 + } 96 + 97 + /** 98 + * Compute the overall date range for a set of tasks. 99 + */ 100 + export function computeTimelineRange(tasks: TimelineTask[]): TimelineRange | null { 101 + if (tasks.length === 0) return null; 102 + 103 + let minTime = Infinity; 104 + let maxTime = -Infinity; 105 + 106 + for (const task of tasks) { 107 + const s = task.startDate.getTime(); 108 + const e = task.endDate.getTime(); 109 + if (s < minTime) minTime = s; 110 + if (e > maxTime) maxTime = e; 111 + } 112 + 113 + const start = new Date(minTime); 114 + const end = new Date(maxTime); 115 + const totalDays = Math.ceil((maxTime - minTime) / (1000 * 60 * 60 * 24)) + 1; 116 + 117 + return { start, end, totalDays }; 118 + } 119 + 120 + /** 121 + * Calculate the horizontal position (0-1) of a date within the timeline range. 122 + */ 123 + export function dateToPosition(date: Date, range: TimelineRange): number { 124 + const total = range.end.getTime() - range.start.getTime(); 125 + if (total === 0) return 0; 126 + const offset = date.getTime() - range.start.getTime(); 127 + return Math.max(0, Math.min(1, offset / total)); 128 + } 129 + 130 + /** 131 + * Compute the left offset and width (0-1) for a task bar. 132 + */ 133 + export function taskBarLayout( 134 + task: TimelineTask, 135 + range: TimelineRange, 136 + ): { left: number; width: number } { 137 + const left = dateToPosition(task.startDate, range); 138 + const right = dateToPosition(task.endDate, range); 139 + return { left, width: Math.max(0, right - left) }; 140 + } 141 + 142 + /** 143 + * Get the duration of a task in days. 144 + */ 145 + export function taskDurationDays(task: TimelineTask): number { 146 + const ms = task.endDate.getTime() - task.startDate.getTime(); 147 + return Math.ceil(ms / (1000 * 60 * 60 * 24)) + 1; 148 + } 149 + 150 + /** 151 + * Group tasks into swimlanes by group field. 152 + */ 153 + export function groupTasksByLane( 154 + tasks: TimelineTask[], 155 + ): Map<string, TimelineTask[]> { 156 + const lanes = new Map<string, TimelineTask[]>(); 157 + for (const task of tasks) { 158 + const key = task.group || '(ungrouped)'; 159 + const lane = lanes.get(key) || []; 160 + lane.push(task); 161 + lanes.set(key, lane); 162 + } 163 + return lanes; 164 + } 165 + 166 + /** 167 + * Sort tasks by start date. 168 + */ 169 + export function sortByStartDate(tasks: TimelineTask[]): TimelineTask[] { 170 + return [...tasks].sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); 171 + } 172 + 173 + /** 174 + * Filter tasks that overlap with a given date range. 175 + */ 176 + export function filterTasksInRange( 177 + tasks: TimelineTask[], 178 + start: Date, 179 + end: Date, 180 + ): TimelineTask[] { 181 + const s = start.getTime(); 182 + const e = end.getTime(); 183 + return tasks.filter( 184 + t => t.startDate.getTime() <= e && t.endDate.getTime() >= s, 185 + ); 186 + } 187 + 188 + /** 189 + * Check if two tasks overlap in time. 190 + */ 191 + export function tasksOverlap(a: TimelineTask, b: TimelineTask): boolean { 192 + return ( 193 + a.startDate.getTime() <= b.endDate.getTime() && 194 + a.endDate.getTime() >= b.startDate.getTime() 195 + ); 196 + } 197 + 198 + /** 199 + * Generate date column headers for the timeline. 200 + * Returns dates spaced evenly across the range. 201 + */ 202 + export function generateDateHeaders( 203 + range: TimelineRange, 204 + maxHeaders: number, 205 + ): Date[] { 206 + if (maxHeaders <= 0) return []; 207 + if (range.totalDays <= maxHeaders) { 208 + // One header per day 209 + const headers: Date[] = []; 210 + const startTime = range.start.getTime(); 211 + for (let i = 0; i < range.totalDays; i++) { 212 + headers.push(new Date(startTime + i * 24 * 60 * 60 * 1000)); 213 + } 214 + return headers; 215 + } 216 + 217 + // Evenly spaced headers 218 + const headers: Date[] = []; 219 + const step = (range.end.getTime() - range.start.getTime()) / (maxHeaders - 1); 220 + for (let i = 0; i < maxHeaders; i++) { 221 + headers.push(new Date(range.start.getTime() + i * step)); 222 + } 223 + return headers; 224 + }
+216
tests/activity-log.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createActivityLog, 4 + recordActivity, 5 + filterByAction, 6 + filterByUser, 7 + filterByDocument, 8 + filterByTimeRange, 9 + paginateEntries, 10 + searchEntries, 11 + countByAction, 12 + countByUser, 13 + lastActivityForDocument, 14 + activeDocuments, 15 + clearActivityLog, 16 + } from '../src/lib/activity-log.js'; 17 + 18 + function buildLog() { 19 + let state = createActivityLog(); 20 + state = recordActivity(state, 'u1', 'Alice', 'create', 'd1', 'Budget', 'Created budget doc'); 21 + state = recordActivity(state, 'u2', 'Bob', 'edit', 'd1', 'Budget', 'Updated totals'); 22 + state = recordActivity(state, 'u1', 'Alice', 'comment', 'd1', 'Budget', 'Looks good'); 23 + state = recordActivity(state, 'u2', 'Bob', 'create', 'd2', 'Notes', ''); 24 + state = recordActivity(state, 'u1', 'Alice', 'share', 'd2', 'Notes', 'Shared with team'); 25 + return state; 26 + } 27 + 28 + describe('createActivityLog', () => { 29 + it('creates empty log with default max', () => { 30 + const state = createActivityLog(); 31 + expect(state.entries).toEqual([]); 32 + expect(state.maxEntries).toBe(1000); 33 + }); 34 + 35 + it('accepts custom maxEntries', () => { 36 + const state = createActivityLog(50); 37 + expect(state.maxEntries).toBe(50); 38 + }); 39 + }); 40 + 41 + describe('recordActivity', () => { 42 + it('adds entry to front of log', () => { 43 + let state = createActivityLog(); 44 + state = recordActivity(state, 'u1', 'Alice', 'create', 'd1', 'Doc', ''); 45 + expect(state.entries).toHaveLength(1); 46 + expect(state.entries[0].action).toBe('create'); 47 + expect(state.entries[0].userId).toBe('u1'); 48 + }); 49 + 50 + it('newest entries come first', () => { 51 + let state = createActivityLog(); 52 + state = recordActivity(state, 'u1', 'Alice', 'create', 'd1', 'Doc', ''); 53 + state = recordActivity(state, 'u2', 'Bob', 'edit', 'd1', 'Doc', ''); 54 + expect(state.entries[0].action).toBe('edit'); 55 + expect(state.entries[1].action).toBe('create'); 56 + }); 57 + 58 + it('trims to maxEntries', () => { 59 + let state = createActivityLog(3); 60 + state = recordActivity(state, 'u1', 'A', 'create', 'd1', 'D', ''); 61 + state = recordActivity(state, 'u1', 'A', 'edit', 'd1', 'D', ''); 62 + state = recordActivity(state, 'u1', 'A', 'comment', 'd1', 'D', ''); 63 + state = recordActivity(state, 'u1', 'A', 'share', 'd1', 'D', ''); 64 + expect(state.entries).toHaveLength(3); 65 + expect(state.entries[0].action).toBe('share'); 66 + }); 67 + 68 + it('generates unique IDs', () => { 69 + let state = createActivityLog(); 70 + state = recordActivity(state, 'u1', 'A', 'create', 'd1', 'D', ''); 71 + state = recordActivity(state, 'u1', 'A', 'edit', 'd1', 'D', ''); 72 + expect(state.entries[0].id).not.toBe(state.entries[1].id); 73 + }); 74 + }); 75 + 76 + describe('filterByAction', () => { 77 + it('returns entries matching action', () => { 78 + const state = buildLog(); 79 + const creates = filterByAction(state, 'create'); 80 + expect(creates).toHaveLength(2); 81 + creates.forEach(e => expect(e.action).toBe('create')); 82 + }); 83 + 84 + it('returns empty for unmatched action', () => { 85 + const state = buildLog(); 86 + expect(filterByAction(state, 'delete')).toEqual([]); 87 + }); 88 + }); 89 + 90 + describe('filterByUser', () => { 91 + it('returns entries for a user', () => { 92 + const state = buildLog(); 93 + const alice = filterByUser(state, 'u1'); 94 + expect(alice).toHaveLength(3); 95 + alice.forEach(e => expect(e.userId).toBe('u1')); 96 + }); 97 + }); 98 + 99 + describe('filterByDocument', () => { 100 + it('returns entries for a document', () => { 101 + const state = buildLog(); 102 + const d1 = filterByDocument(state, 'd1'); 103 + expect(d1).toHaveLength(3); 104 + d1.forEach(e => expect(e.documentId).toBe('d1')); 105 + }); 106 + }); 107 + 108 + describe('filterByTimeRange', () => { 109 + it('returns entries within range', () => { 110 + const state = buildLog(); 111 + const now = Date.now(); 112 + const result = filterByTimeRange(state, now - 1000, now + 1000); 113 + expect(result.length).toBe(5); 114 + }); 115 + 116 + it('returns empty for out-of-range', () => { 117 + const state = buildLog(); 118 + const result = filterByTimeRange(state, 0, 1); 119 + expect(result).toEqual([]); 120 + }); 121 + }); 122 + 123 + describe('paginateEntries', () => { 124 + it('returns correct page', () => { 125 + const state = buildLog(); 126 + const page0 = paginateEntries(state.entries, 0, 2); 127 + expect(page0).toHaveLength(2); 128 + 129 + const page1 = paginateEntries(state.entries, 1, 2); 130 + expect(page1).toHaveLength(2); 131 + 132 + const page2 = paginateEntries(state.entries, 2, 2); 133 + expect(page2).toHaveLength(1); 134 + }); 135 + 136 + it('returns empty for out-of-range page', () => { 137 + const state = buildLog(); 138 + expect(paginateEntries(state.entries, 10, 2)).toEqual([]); 139 + }); 140 + }); 141 + 142 + describe('searchEntries', () => { 143 + it('matches document name', () => { 144 + const state = buildLog(); 145 + const result = searchEntries(state, 'budget'); 146 + expect(result).toHaveLength(3); 147 + }); 148 + 149 + it('matches user name', () => { 150 + const state = buildLog(); 151 + const result = searchEntries(state, 'bob'); 152 + expect(result).toHaveLength(2); 153 + }); 154 + 155 + it('matches details', () => { 156 + const state = buildLog(); 157 + const result = searchEntries(state, 'totals'); 158 + expect(result).toHaveLength(1); 159 + }); 160 + 161 + it('returns all for empty query', () => { 162 + const state = buildLog(); 163 + expect(searchEntries(state, '')).toHaveLength(5); 164 + }); 165 + }); 166 + 167 + describe('countByAction', () => { 168 + it('counts entries per action', () => { 169 + const state = buildLog(); 170 + const counts = countByAction(state); 171 + expect(counts['create']).toBe(2); 172 + expect(counts['edit']).toBe(1); 173 + expect(counts['comment']).toBe(1); 174 + expect(counts['share']).toBe(1); 175 + }); 176 + }); 177 + 178 + describe('countByUser', () => { 179 + it('counts entries per user', () => { 180 + const state = buildLog(); 181 + const counts = countByUser(state); 182 + expect(counts['u1']).toBe(3); 183 + expect(counts['u2']).toBe(2); 184 + }); 185 + }); 186 + 187 + describe('lastActivityForDocument', () => { 188 + it('returns most recent entry for doc', () => { 189 + const state = buildLog(); 190 + const last = lastActivityForDocument(state, 'd1'); 191 + expect(last).not.toBeNull(); 192 + expect(last!.action).toBe('comment'); 193 + }); 194 + 195 + it('returns null for unknown doc', () => { 196 + const state = buildLog(); 197 + expect(lastActivityForDocument(state, 'unknown')).toBeNull(); 198 + }); 199 + }); 200 + 201 + describe('activeDocuments', () => { 202 + it('returns unique doc IDs in order of appearance', () => { 203 + const state = buildLog(); 204 + const docs = activeDocuments(state); 205 + expect(docs).toEqual(['d2', 'd1']); 206 + }); 207 + }); 208 + 209 + describe('clearActivityLog', () => { 210 + it('removes all entries', () => { 211 + const state = buildLog(); 212 + const cleared = clearActivityLog(state); 213 + expect(cleared.entries).toEqual([]); 214 + expect(cleared.maxEntries).toBe(1000); 215 + }); 216 + });
+205
tests/cursor-presence.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createPresenceState, 4 + assignColor, 5 + updateCursor, 6 + removeUser, 7 + isCursorStale, 8 + getActiveCursors, 9 + getStaleCursors, 10 + pruneStaleUsers, 11 + activeUserCount, 12 + cursorsAtPosition, 13 + cursorLabel, 14 + type UserPresence, 15 + } from '../src/lib/cursor-presence.js'; 16 + 17 + const now = Date.now(); 18 + 19 + describe('createPresenceState', () => { 20 + it('creates state with local user ID', () => { 21 + const state = createPresenceState('me'); 22 + expect(state.localUserId).toBe('me'); 23 + expect(state.users.size).toBe(0); 24 + expect(state.staleThreshold).toBe(30000); 25 + }); 26 + 27 + it('accepts custom stale threshold', () => { 28 + const state = createPresenceState('me', 5000); 29 + expect(state.staleThreshold).toBe(5000); 30 + }); 31 + }); 32 + 33 + describe('assignColor', () => { 34 + it('returns colors from palette', () => { 35 + expect(assignColor(0)).toBe('#e74c3c'); 36 + expect(assignColor(1)).toBe('#3498db'); 37 + }); 38 + 39 + it('wraps around for large indices', () => { 40 + expect(assignColor(8)).toBe(assignColor(0)); 41 + }); 42 + }); 43 + 44 + describe('updateCursor', () => { 45 + it('adds a new remote user cursor', () => { 46 + let state = createPresenceState('me'); 47 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); 48 + expect(state.users.size).toBe(1); 49 + expect(state.users.get('u1')!.userName).toBe('Alice'); 50 + expect(state.users.get('u1')!.cursor!.anchor).toBe('A1'); 51 + }); 52 + 53 + it('ignores local user', () => { 54 + let state = createPresenceState('me'); 55 + state = updateCursor(state, 'me', 'Me', { anchor: 'A1', head: 'A1' }); 56 + expect(state.users.size).toBe(0); 57 + }); 58 + 59 + it('updates existing user cursor', () => { 60 + let state = createPresenceState('me'); 61 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); 62 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'B2', head: 'B2' }); 63 + expect(state.users.size).toBe(1); 64 + expect(state.users.get('u1')!.cursor!.anchor).toBe('B2'); 65 + }); 66 + 67 + it('preserves existing color on update', () => { 68 + let state = createPresenceState('me'); 69 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }, '#ff0000'); 70 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'B2', head: 'B2' }); 71 + expect(state.users.get('u1')!.color).toBe('#ff0000'); 72 + }); 73 + 74 + it('accepts explicit color', () => { 75 + let state = createPresenceState('me'); 76 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }, '#abc'); 77 + expect(state.users.get('u1')!.color).toBe('#abc'); 78 + }); 79 + }); 80 + 81 + describe('removeUser', () => { 82 + it('removes an existing user', () => { 83 + let state = createPresenceState('me'); 84 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); 85 + state = removeUser(state, 'u1'); 86 + expect(state.users.size).toBe(0); 87 + }); 88 + 89 + it('returns same state if user not found', () => { 90 + const state = createPresenceState('me'); 91 + expect(removeUser(state, 'unknown')).toBe(state); 92 + }); 93 + }); 94 + 95 + describe('isCursorStale', () => { 96 + it('returns false for recent cursor', () => { 97 + const presence: UserPresence = { 98 + userId: 'u1', userName: 'A', color: '#f00', 99 + cursor: { anchor: 'A1', head: 'A1' }, lastSeen: now, 100 + }; 101 + expect(isCursorStale(presence, 30000, now + 1000)).toBe(false); 102 + }); 103 + 104 + it('returns true for old cursor', () => { 105 + const presence: UserPresence = { 106 + userId: 'u1', userName: 'A', color: '#f00', 107 + cursor: { anchor: 'A1', head: 'A1' }, lastSeen: now, 108 + }; 109 + expect(isCursorStale(presence, 30000, now + 60000)).toBe(true); 110 + }); 111 + }); 112 + 113 + describe('getActiveCursors', () => { 114 + it('returns non-stale cursors', () => { 115 + let state = createPresenceState('me', 30000); 116 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); 117 + state = updateCursor(state, 'u2', 'Bob', { anchor: 'B1', head: 'B1' }); 118 + const active = getActiveCursors(state); 119 + expect(active).toHaveLength(2); 120 + }); 121 + 122 + it('excludes stale cursors', () => { 123 + let state = createPresenceState('me', 1000); 124 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); 125 + const active = getActiveCursors(state, Date.now() + 5000); 126 + expect(active).toHaveLength(0); 127 + }); 128 + 129 + it('excludes users with null cursor', () => { 130 + let state = createPresenceState('me'); 131 + const users = new Map(state.users); 132 + users.set('u1', { 133 + userId: 'u1', userName: 'Alice', color: '#f00', 134 + cursor: null, lastSeen: Date.now(), 135 + }); 136 + state = { ...state, users }; 137 + expect(getActiveCursors(state)).toHaveLength(0); 138 + }); 139 + }); 140 + 141 + describe('getStaleCursors', () => { 142 + it('returns stale cursors', () => { 143 + let state = createPresenceState('me', 1000); 144 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); 145 + const stale = getStaleCursors(state, Date.now() + 5000); 146 + expect(stale).toHaveLength(1); 147 + }); 148 + }); 149 + 150 + describe('pruneStaleUsers', () => { 151 + it('removes stale users', () => { 152 + let state = createPresenceState('me', 1000); 153 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); 154 + const pruned = pruneStaleUsers(state, Date.now() + 5000); 155 + expect(pruned.users.size).toBe(0); 156 + }); 157 + 158 + it('returns same state if no stale users', () => { 159 + let state = createPresenceState('me', 30000); 160 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); 161 + expect(pruneStaleUsers(state)).toBe(state); 162 + }); 163 + }); 164 + 165 + describe('activeUserCount', () => { 166 + it('counts active users', () => { 167 + let state = createPresenceState('me'); 168 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); 169 + state = updateCursor(state, 'u2', 'Bob', { anchor: 'B1', head: 'B1' }); 170 + expect(activeUserCount(state)).toBe(2); 171 + }); 172 + }); 173 + 174 + describe('cursorsAtPosition', () => { 175 + it('finds cursors at a specific position', () => { 176 + let state = createPresenceState('me'); 177 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); 178 + state = updateCursor(state, 'u2', 'Bob', { anchor: 'B1', head: 'B1' }); 179 + const at = cursorsAtPosition(state, 'A1'); 180 + expect(at).toHaveLength(1); 181 + expect(at[0].userName).toBe('Alice'); 182 + }); 183 + 184 + it('matches head position too', () => { 185 + let state = createPresenceState('me'); 186 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'C3' }); 187 + expect(cursorsAtPosition(state, 'C3')).toHaveLength(1); 188 + }); 189 + 190 + it('returns empty for no cursors at position', () => { 191 + let state = createPresenceState('me'); 192 + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); 193 + expect(cursorsAtPosition(state, 'Z9')).toHaveLength(0); 194 + }); 195 + }); 196 + 197 + describe('cursorLabel', () => { 198 + it('returns user name', () => { 199 + const presence: UserPresence = { 200 + userId: 'u1', userName: 'Alice', color: '#f00', 201 + cursor: { anchor: 'A1', head: 'A1' }, lastSeen: now, 202 + }; 203 + expect(cursorLabel(presence)).toBe('Alice'); 204 + }); 205 + });
+241
tests/timeline-view.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + parseTimelineDate, 4 + extractTasks, 5 + computeTimelineRange, 6 + dateToPosition, 7 + taskBarLayout, 8 + taskDurationDays, 9 + groupTasksByLane, 10 + sortByStartDate, 11 + filterTasksInRange, 12 + tasksOverlap, 13 + generateDateHeaders, 14 + type TimelineTask, 15 + type TimelineConfig, 16 + type TimelineRange, 17 + } from '../src/sheets/timeline-view.js'; 18 + 19 + const d = (s: string) => new Date(`${s}T12:00:00`); 20 + 21 + const taskA: TimelineTask = { 22 + rowIndex: 1, label: 'Design', startDate: d('2026-03-01'), endDate: d('2026-03-05'), 23 + progress: 100, group: 'Phase 1', 24 + }; 25 + const taskB: TimelineTask = { 26 + rowIndex: 2, label: 'Build', startDate: d('2026-03-04'), endDate: d('2026-03-10'), 27 + progress: 50, group: 'Phase 1', 28 + }; 29 + const taskC: TimelineTask = { 30 + rowIndex: 3, label: 'Test', startDate: d('2026-03-11'), endDate: d('2026-03-15'), 31 + progress: 0, group: 'Phase 2', 32 + }; 33 + 34 + describe('parseTimelineDate', () => { 35 + it('parses date string', () => { 36 + const result = parseTimelineDate('2026-03-15T12:00:00'); 37 + expect(result).toBeInstanceOf(Date); 38 + expect(result!.getDate()).toBe(15); 39 + }); 40 + 41 + it('accepts Date object', () => { 42 + const input = new Date('2026-03-15T12:00:00'); 43 + expect(parseTimelineDate(input)).toBe(input); 44 + }); 45 + 46 + it('accepts timestamp number', () => { 47 + const ts = new Date('2026-03-15T12:00:00').getTime(); 48 + const result = parseTimelineDate(ts); 49 + expect(result!.getDate()).toBe(15); 50 + }); 51 + 52 + it('returns null for empty value', () => { 53 + expect(parseTimelineDate('')).toBeNull(); 54 + expect(parseTimelineDate(null)).toBeNull(); 55 + expect(parseTimelineDate(undefined)).toBeNull(); 56 + }); 57 + 58 + it('returns null for invalid date string', () => { 59 + expect(parseTimelineDate('not-a-date')).toBeNull(); 60 + }); 61 + 62 + it('returns null for invalid Date object', () => { 63 + expect(parseTimelineDate(new Date('invalid'))).toBeNull(); 64 + }); 65 + }); 66 + 67 + describe('extractTasks', () => { 68 + const colToLetter = (c: number) => String.fromCharCode(65 + c); 69 + const config: TimelineConfig = { 70 + labelColumn: 0, startDateColumn: 1, endDateColumn: 2, 71 + progressColumn: 3, groupColumn: 4, 72 + }; 73 + 74 + it('extracts tasks from row data', () => { 75 + const rows: Map<string, unknown>[] = [ 76 + new Map([['A1', 'Design'], ['B1', '2026-03-01T12:00:00'], ['C1', '2026-03-05T12:00:00'], ['D1', 80], ['E1', 'Dev']]), 77 + new Map([['A2', 'Test'], ['B2', '2026-03-06T12:00:00'], ['C2', '2026-03-10T12:00:00'], ['D2', 0], ['E2', 'QA']]), 78 + ]; 79 + const tasks = extractTasks(rows, config, colToLetter); 80 + expect(tasks).toHaveLength(2); 81 + expect(tasks[0].label).toBe('Design'); 82 + expect(tasks[0].progress).toBe(80); 83 + expect(tasks[0].group).toBe('Dev'); 84 + expect(tasks[1].label).toBe('Test'); 85 + }); 86 + 87 + it('skips rows with missing dates', () => { 88 + const rows: Map<string, unknown>[] = [ 89 + new Map([['A1', 'Design'], ['B1', ''], ['C1', '2026-03-05T12:00:00']]), 90 + ]; 91 + const tasks = extractTasks(rows, { ...config, progressColumn: null, groupColumn: null }, colToLetter); 92 + expect(tasks).toHaveLength(0); 93 + }); 94 + 95 + it('skips rows with missing label', () => { 96 + const rows: Map<string, unknown>[] = [ 97 + new Map([['A1', ''], ['B1', '2026-03-01T12:00:00'], ['C1', '2026-03-05T12:00:00']]), 98 + ]; 99 + const tasks = extractTasks(rows, { ...config, progressColumn: null, groupColumn: null }, colToLetter); 100 + expect(tasks).toHaveLength(0); 101 + }); 102 + 103 + it('clamps progress to 0-100', () => { 104 + const rows: Map<string, unknown>[] = [ 105 + new Map([['A1', 'X'], ['B1', '2026-03-01T12:00:00'], ['C1', '2026-03-05T12:00:00'], ['D1', 150]]), 106 + ]; 107 + const tasks = extractTasks(rows, { ...config, groupColumn: null }, colToLetter); 108 + expect(tasks[0].progress).toBe(100); 109 + }); 110 + }); 111 + 112 + describe('computeTimelineRange', () => { 113 + it('computes range from tasks', () => { 114 + const range = computeTimelineRange([taskA, taskB, taskC]); 115 + expect(range).not.toBeNull(); 116 + expect(range!.start.getTime()).toBe(taskA.startDate.getTime()); 117 + expect(range!.end.getTime()).toBe(taskC.endDate.getTime()); 118 + expect(range!.totalDays).toBeGreaterThan(0); 119 + }); 120 + 121 + it('returns null for empty tasks', () => { 122 + expect(computeTimelineRange([])).toBeNull(); 123 + }); 124 + }); 125 + 126 + describe('dateToPosition', () => { 127 + it('returns 0 for start date', () => { 128 + const range = computeTimelineRange([taskA, taskC])!; 129 + expect(dateToPosition(range.start, range)).toBe(0); 130 + }); 131 + 132 + it('returns 1 for end date', () => { 133 + const range = computeTimelineRange([taskA, taskC])!; 134 + expect(dateToPosition(range.end, range)).toBe(1); 135 + }); 136 + 137 + it('returns 0.5 for midpoint', () => { 138 + const start = d('2026-03-01'); 139 + const end = d('2026-03-11'); 140 + const range: TimelineRange = { 141 + start, end, totalDays: 11, 142 + }; 143 + const mid = d('2026-03-06'); 144 + expect(dateToPosition(mid, range)).toBeCloseTo(0.5, 1); 145 + }); 146 + 147 + it('clamps to 0-1', () => { 148 + const range = computeTimelineRange([taskA, taskC])!; 149 + const before = new Date(range.start.getTime() - 86400000); 150 + expect(dateToPosition(before, range)).toBe(0); 151 + }); 152 + }); 153 + 154 + describe('taskBarLayout', () => { 155 + it('returns left and width in 0-1 range', () => { 156 + const range = computeTimelineRange([taskA, taskC])!; 157 + const layout = taskBarLayout(taskA, range); 158 + expect(layout.left).toBeGreaterThanOrEqual(0); 159 + expect(layout.width).toBeGreaterThan(0); 160 + expect(layout.left + layout.width).toBeLessThanOrEqual(1); 161 + }); 162 + }); 163 + 164 + describe('taskDurationDays', () => { 165 + it('calculates duration inclusive', () => { 166 + expect(taskDurationDays(taskA)).toBe(5); // Mar 1-5 167 + }); 168 + }); 169 + 170 + describe('groupTasksByLane', () => { 171 + it('groups by group field', () => { 172 + const lanes = groupTasksByLane([taskA, taskB, taskC]); 173 + expect(lanes.size).toBe(2); 174 + expect(lanes.get('Phase 1')).toHaveLength(2); 175 + expect(lanes.get('Phase 2')).toHaveLength(1); 176 + }); 177 + 178 + it('uses (ungrouped) for empty group', () => { 179 + const ungrouped: TimelineTask = { ...taskA, group: '' }; 180 + const lanes = groupTasksByLane([ungrouped]); 181 + expect(lanes.has('(ungrouped)')).toBe(true); 182 + }); 183 + }); 184 + 185 + describe('sortByStartDate', () => { 186 + it('sorts tasks by start date', () => { 187 + const sorted = sortByStartDate([taskC, taskA, taskB]); 188 + expect(sorted[0].label).toBe('Design'); 189 + expect(sorted[1].label).toBe('Build'); 190 + expect(sorted[2].label).toBe('Test'); 191 + }); 192 + }); 193 + 194 + describe('filterTasksInRange', () => { 195 + it('includes overlapping tasks', () => { 196 + const result = filterTasksInRange([taskA, taskB, taskC], d('2026-03-04'), d('2026-03-06')); 197 + expect(result).toHaveLength(2); // taskA and taskB overlap 198 + }); 199 + 200 + it('excludes non-overlapping tasks', () => { 201 + const result = filterTasksInRange([taskA, taskC], d('2026-03-06'), d('2026-03-08')); 202 + expect(result).toHaveLength(0); 203 + }); 204 + }); 205 + 206 + describe('tasksOverlap', () => { 207 + it('detects overlapping tasks', () => { 208 + expect(tasksOverlap(taskA, taskB)).toBe(true); 209 + }); 210 + 211 + it('detects non-overlapping tasks', () => { 212 + expect(tasksOverlap(taskA, taskC)).toBe(false); 213 + }); 214 + }); 215 + 216 + describe('generateDateHeaders', () => { 217 + it('generates one per day for short range', () => { 218 + const range: TimelineRange = { 219 + start: d('2026-03-01'), end: d('2026-03-03'), totalDays: 3, 220 + }; 221 + const headers = generateDateHeaders(range, 10); 222 + expect(headers).toHaveLength(3); 223 + }); 224 + 225 + it('generates evenly spaced headers for long range', () => { 226 + const range: TimelineRange = { 227 + start: d('2026-03-01'), end: d('2026-03-31'), totalDays: 31, 228 + }; 229 + const headers = generateDateHeaders(range, 5); 230 + expect(headers).toHaveLength(5); 231 + expect(headers[0].getTime()).toBe(range.start.getTime()); 232 + expect(headers[4].getTime()).toBe(range.end.getTime()); 233 + }); 234 + 235 + it('returns empty for maxHeaders 0', () => { 236 + const range: TimelineRange = { 237 + start: d('2026-03-01'), end: d('2026-03-03'), totalDays: 3, 238 + }; 239 + expect(generateDateHeaders(range, 0)).toEqual([]); 240 + }); 241 + });