diff --git a/src/lib/activity-log.ts b/src/lib/activity-log.ts new file mode 100644 index 0000000..d17eae2 --- /dev/null +++ b/src/lib/activity-log.ts @@ -0,0 +1,207 @@ +/** + * Activity Log — encrypted audit trail for document actions. + * + * Pure logic module: event creation, filtering, pagination, aggregation. + * Storage and encryption handled by the collaboration layer. + */ + +export type ActivityAction = + | 'create' + | 'edit' + | 'delete' + | 'rename' + | 'share' + | 'unshare' + | 'comment' + | 'resolve' + | 'restore' + | 'move' + | 'view'; + +export interface ActivityEntry { + id: string; + timestamp: number; + userId: string; + userName: string; + action: ActivityAction; + documentId: string; + documentName: string; + /** Optional details (e.g., old name for rename, comment text) */ + details: string; +} + +export interface ActivityLogState { + entries: ActivityEntry[]; + /** Max entries to retain in memory */ + maxEntries: number; +} + +let _entryCounter = 0; + +/** + * Create initial activity log state. + */ +export function createActivityLog(maxEntries = 1000): ActivityLogState { + return { entries: [], maxEntries }; +} + +/** + * Record a new activity entry. + */ +export function recordActivity( + state: ActivityLogState, + userId: string, + userName: string, + action: ActivityAction, + documentId: string, + documentName: string, + details = '', +): ActivityLogState { + const entry: ActivityEntry = { + id: `act-${Date.now()}-${++_entryCounter}`, + timestamp: Date.now(), + userId, + userName, + action, + documentId, + documentName, + details, + }; + + const entries = [entry, ...state.entries]; + // Trim to maxEntries + if (entries.length > state.maxEntries) { + entries.length = state.maxEntries; + } + + return { ...state, entries }; +} + +/** + * Filter entries by action type. + */ +export function filterByAction( + state: ActivityLogState, + action: ActivityAction, +): ActivityEntry[] { + return state.entries.filter(e => e.action === action); +} + +/** + * Filter entries by user. + */ +export function filterByUser( + state: ActivityLogState, + userId: string, +): ActivityEntry[] { + return state.entries.filter(e => e.userId === userId); +} + +/** + * Filter entries by document. + */ +export function filterByDocument( + state: ActivityLogState, + documentId: string, +): ActivityEntry[] { + return state.entries.filter(e => e.documentId === documentId); +} + +/** + * Filter entries within a time range. + */ +export function filterByTimeRange( + state: ActivityLogState, + start: number, + end: number, +): ActivityEntry[] { + return state.entries.filter(e => e.timestamp >= start && e.timestamp <= end); +} + +/** + * Paginate entries. + */ +export function paginateEntries( + entries: ActivityEntry[], + page: number, + pageSize: number, +): ActivityEntry[] { + const offset = page * pageSize; + return entries.slice(offset, offset + pageSize); +} + +/** + * Search entries by text (matches document name, user name, or details). + */ +export function searchEntries( + state: ActivityLogState, + query: string, +): ActivityEntry[] { + if (!query) return state.entries; + + const lower = query.toLowerCase(); + return state.entries.filter( + e => + e.documentName.toLowerCase().includes(lower) || + e.userName.toLowerCase().includes(lower) || + e.details.toLowerCase().includes(lower), + ); +} + +/** + * Count entries grouped by action type. + */ +export function countByAction( + state: ActivityLogState, +): Record { + const counts: Record = {}; + for (const e of state.entries) { + counts[e.action] = (counts[e.action] || 0) + 1; + } + return counts; +} + +/** + * Count entries grouped by user. + */ +export function countByUser( + state: ActivityLogState, +): Record { + const counts: Record = {}; + for (const e of state.entries) { + counts[e.userId] = (counts[e.userId] || 0) + 1; + } + return counts; +} + +/** + * Get the most recent activity for a document. + */ +export function lastActivityForDocument( + state: ActivityLogState, + documentId: string, +): ActivityEntry | null { + return state.entries.find(e => e.documentId === documentId) ?? null; +} + +/** + * Get unique document IDs that have activity. + */ +export function activeDocuments(state: ActivityLogState): string[] { + const seen = new Set(); + const result: string[] = []; + for (const e of state.entries) { + if (!seen.has(e.documentId)) { + seen.add(e.documentId); + result.push(e.documentId); + } + } + return result; +} + +/** + * Clear all entries. + */ +export function clearActivityLog(state: ActivityLogState): ActivityLogState { + return { ...state, entries: [] }; +} diff --git a/src/lib/cursor-presence.ts b/src/lib/cursor-presence.ts new file mode 100644 index 0000000..f68aebf --- /dev/null +++ b/src/lib/cursor-presence.ts @@ -0,0 +1,185 @@ +/** + * Cursor Presence Overlay — collaborative cursor/selection display. + * + * Pure logic module: cursor state management, color assignment, staleness detection. + * DOM rendering (overlays, labels) handled in the editor UI layer. + */ + +export interface CursorPosition { + /** For docs: character offset. For sheets: cell ID like "B3" */ + anchor: string; + /** Selection end (same as anchor if no selection) */ + head: string; +} + +export interface UserPresence { + userId: string; + userName: string; + color: string; + cursor: CursorPosition | null; + lastSeen: number; +} + +export interface PresenceState { + /** Current user's ID (excluded from display) */ + localUserId: string; + /** Remote user presences keyed by userId */ + users: Map; + /** Milliseconds before a cursor is considered stale */ + staleThreshold: number; +} + +/** Default palette for assigning user colors */ +const PRESENCE_COLORS = [ + '#e74c3c', '#3498db', '#2ecc71', '#f39c12', + '#9b59b6', '#1abc9c', '#e67e22', '#e84393', +]; + +/** + * Create initial presence state. + */ +export function createPresenceState( + localUserId: string, + staleThreshold = 30000, +): PresenceState { + return { + localUserId, + users: new Map(), + staleThreshold, + }; +} + +/** + * Assign a color to a user based on their index. + */ +export function assignColor(index: number): string { + return PRESENCE_COLORS[index % PRESENCE_COLORS.length]; +} + +/** + * Update a remote user's cursor position. + */ +export function updateCursor( + state: PresenceState, + userId: string, + userName: string, + cursor: CursorPosition, + color?: string, +): PresenceState { + if (userId === state.localUserId) return state; + + const users = new Map(state.users); + const existing = users.get(userId); + users.set(userId, { + userId, + userName, + color: color ?? existing?.color ?? assignColor(users.size), + cursor, + lastSeen: Date.now(), + }); + + return { ...state, users }; +} + +/** + * Remove a user's presence (e.g., on disconnect). + */ +export function removeUser( + state: PresenceState, + userId: string, +): PresenceState { + if (!state.users.has(userId)) return state; + const users = new Map(state.users); + users.delete(userId); + return { ...state, users }; +} + +/** + * Check if a user's cursor is stale. + */ +export function isCursorStale( + presence: UserPresence, + staleThreshold: number, + now = Date.now(), +): boolean { + return now - presence.lastSeen > staleThreshold; +} + +/** + * Get all active (non-stale) remote cursors. + */ +export function getActiveCursors( + state: PresenceState, + now = Date.now(), +): UserPresence[] { + const result: UserPresence[] = []; + for (const presence of state.users.values()) { + if (presence.cursor && !isCursorStale(presence, state.staleThreshold, now)) { + result.push(presence); + } + } + return result; +} + +/** + * Get all stale cursors (for cleanup or dimming). + */ +export function getStaleCursors( + state: PresenceState, + now = Date.now(), +): UserPresence[] { + const result: UserPresence[] = []; + for (const presence of state.users.values()) { + if (isCursorStale(presence, state.staleThreshold, now)) { + result.push(presence); + } + } + return result; +} + +/** + * Remove all stale users from state. + */ +export function pruneStaleUsers( + state: PresenceState, + now = Date.now(), +): PresenceState { + const users = new Map(); + for (const [id, presence] of state.users) { + if (!isCursorStale(presence, state.staleThreshold, now)) { + users.set(id, presence); + } + } + if (users.size === state.users.size) return state; + return { ...state, users }; +} + +/** + * Count active remote users. + */ +export function activeUserCount( + state: PresenceState, + now = Date.now(), +): number { + return getActiveCursors(state, now).length; +} + +/** + * Check if a specific cell/position has a remote cursor. + */ +export function cursorsAtPosition( + state: PresenceState, + position: string, + now = Date.now(), +): UserPresence[] { + return getActiveCursors(state, now).filter( + p => p.cursor!.anchor === position || p.cursor!.head === position, + ); +} + +/** + * Build cursor label text (shown next to cursor overlay). + */ +export function cursorLabel(presence: UserPresence): string { + return presence.userName; +} diff --git a/src/sheets/timeline-view.ts b/src/sheets/timeline-view.ts new file mode 100644 index 0000000..03c29bd --- /dev/null +++ b/src/sheets/timeline-view.ts @@ -0,0 +1,224 @@ +/** + * Timeline / Gantt View — date-range visualization for sheets. + * + * Pure logic module: task extraction, date math, layout computation. + * Canvas/DOM rendering handled in the sheets UI layer. + */ + +export interface TimelineTask { + rowIndex: number; + label: string; + startDate: Date; + endDate: Date; + /** Progress 0-100 */ + progress: number; + /** Optional group/category for swimlanes */ + group: string; +} + +export interface TimelineConfig { + labelColumn: number; + startDateColumn: number; + endDateColumn: number; + progressColumn: number | null; + groupColumn: number | null; +} + +export interface TimelineRange { + start: Date; + end: Date; + totalDays: number; +} + +/** + * Parse a date value from a cell (string or Date). + */ +export function parseTimelineDate(value: unknown): Date | null { + if (!value) return null; + if (value instanceof Date) { + return isNaN(value.getTime()) ? null : value; + } + if (typeof value === 'string') { + const d = new Date(value); + return isNaN(d.getTime()) ? null : d; + } + if (typeof value === 'number') { + const d = new Date(value); + return isNaN(d.getTime()) ? null : d; + } + return null; +} + +/** + * Extract timeline tasks from sheet data. + */ +export function extractTasks( + rows: Map[], + config: TimelineConfig, + colToLetter: (col: number) => string, + startRow = 1, +): TimelineTask[] { + const tasks: TimelineTask[] = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const rowIndex = startRow + i; + const labelKey = `${colToLetter(config.labelColumn)}${rowIndex}`; + const startKey = `${colToLetter(config.startDateColumn)}${rowIndex}`; + const endKey = `${colToLetter(config.endDateColumn)}${rowIndex}`; + + const label = String(row.get(labelKey) ?? ''); + const startDate = parseTimelineDate(row.get(startKey)); + const endDate = parseTimelineDate(row.get(endKey)); + + if (!startDate || !endDate || !label) continue; + + let progress = 0; + if (config.progressColumn !== null) { + const progKey = `${colToLetter(config.progressColumn)}${rowIndex}`; + const progVal = row.get(progKey); + if (typeof progVal === 'number') { + progress = Math.max(0, Math.min(100, progVal)); + } + } + + let group = ''; + if (config.groupColumn !== null) { + const groupKey = `${colToLetter(config.groupColumn)}${rowIndex}`; + group = String(row.get(groupKey) ?? ''); + } + + tasks.push({ rowIndex, label, startDate, endDate, progress, group }); + } + + return tasks; +} + +/** + * Compute the overall date range for a set of tasks. + */ +export function computeTimelineRange(tasks: TimelineTask[]): TimelineRange | null { + if (tasks.length === 0) return null; + + let minTime = Infinity; + let maxTime = -Infinity; + + for (const task of tasks) { + const s = task.startDate.getTime(); + const e = task.endDate.getTime(); + if (s < minTime) minTime = s; + if (e > maxTime) maxTime = e; + } + + const start = new Date(minTime); + const end = new Date(maxTime); + const totalDays = Math.ceil((maxTime - minTime) / (1000 * 60 * 60 * 24)) + 1; + + return { start, end, totalDays }; +} + +/** + * Calculate the horizontal position (0-1) of a date within the timeline range. + */ +export function dateToPosition(date: Date, range: TimelineRange): number { + const total = range.end.getTime() - range.start.getTime(); + if (total === 0) return 0; + const offset = date.getTime() - range.start.getTime(); + return Math.max(0, Math.min(1, offset / total)); +} + +/** + * Compute the left offset and width (0-1) for a task bar. + */ +export function taskBarLayout( + task: TimelineTask, + range: TimelineRange, +): { left: number; width: number } { + const left = dateToPosition(task.startDate, range); + const right = dateToPosition(task.endDate, range); + return { left, width: Math.max(0, right - left) }; +} + +/** + * Get the duration of a task in days. + */ +export function taskDurationDays(task: TimelineTask): number { + const ms = task.endDate.getTime() - task.startDate.getTime(); + return Math.ceil(ms / (1000 * 60 * 60 * 24)) + 1; +} + +/** + * Group tasks into swimlanes by group field. + */ +export function groupTasksByLane( + tasks: TimelineTask[], +): Map { + const lanes = new Map(); + for (const task of tasks) { + const key = task.group || '(ungrouped)'; + const lane = lanes.get(key) || []; + lane.push(task); + lanes.set(key, lane); + } + return lanes; +} + +/** + * Sort tasks by start date. + */ +export function sortByStartDate(tasks: TimelineTask[]): TimelineTask[] { + return [...tasks].sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); +} + +/** + * Filter tasks that overlap with a given date range. + */ +export function filterTasksInRange( + tasks: TimelineTask[], + start: Date, + end: Date, +): TimelineTask[] { + const s = start.getTime(); + const e = end.getTime(); + return tasks.filter( + t => t.startDate.getTime() <= e && t.endDate.getTime() >= s, + ); +} + +/** + * Check if two tasks overlap in time. + */ +export function tasksOverlap(a: TimelineTask, b: TimelineTask): boolean { + return ( + a.startDate.getTime() <= b.endDate.getTime() && + a.endDate.getTime() >= b.startDate.getTime() + ); +} + +/** + * Generate date column headers for the timeline. + * Returns dates spaced evenly across the range. + */ +export function generateDateHeaders( + range: TimelineRange, + maxHeaders: number, +): Date[] { + if (maxHeaders <= 0) return []; + if (range.totalDays <= maxHeaders) { + // One header per day + const headers: Date[] = []; + const startTime = range.start.getTime(); + for (let i = 0; i < range.totalDays; i++) { + headers.push(new Date(startTime + i * 24 * 60 * 60 * 1000)); + } + return headers; + } + + // Evenly spaced headers + const headers: Date[] = []; + const step = (range.end.getTime() - range.start.getTime()) / (maxHeaders - 1); + for (let i = 0; i < maxHeaders; i++) { + headers.push(new Date(range.start.getTime() + i * step)); + } + return headers; +} diff --git a/tests/activity-log.test.ts b/tests/activity-log.test.ts new file mode 100644 index 0000000..ed5f76c --- /dev/null +++ b/tests/activity-log.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from 'vitest'; +import { + createActivityLog, + recordActivity, + filterByAction, + filterByUser, + filterByDocument, + filterByTimeRange, + paginateEntries, + searchEntries, + countByAction, + countByUser, + lastActivityForDocument, + activeDocuments, + clearActivityLog, +} from '../src/lib/activity-log.js'; + +function buildLog() { + let state = createActivityLog(); + state = recordActivity(state, 'u1', 'Alice', 'create', 'd1', 'Budget', 'Created budget doc'); + state = recordActivity(state, 'u2', 'Bob', 'edit', 'd1', 'Budget', 'Updated totals'); + state = recordActivity(state, 'u1', 'Alice', 'comment', 'd1', 'Budget', 'Looks good'); + state = recordActivity(state, 'u2', 'Bob', 'create', 'd2', 'Notes', ''); + state = recordActivity(state, 'u1', 'Alice', 'share', 'd2', 'Notes', 'Shared with team'); + return state; +} + +describe('createActivityLog', () => { + it('creates empty log with default max', () => { + const state = createActivityLog(); + expect(state.entries).toEqual([]); + expect(state.maxEntries).toBe(1000); + }); + + it('accepts custom maxEntries', () => { + const state = createActivityLog(50); + expect(state.maxEntries).toBe(50); + }); +}); + +describe('recordActivity', () => { + it('adds entry to front of log', () => { + let state = createActivityLog(); + state = recordActivity(state, 'u1', 'Alice', 'create', 'd1', 'Doc', ''); + expect(state.entries).toHaveLength(1); + expect(state.entries[0].action).toBe('create'); + expect(state.entries[0].userId).toBe('u1'); + }); + + it('newest entries come first', () => { + let state = createActivityLog(); + state = recordActivity(state, 'u1', 'Alice', 'create', 'd1', 'Doc', ''); + state = recordActivity(state, 'u2', 'Bob', 'edit', 'd1', 'Doc', ''); + expect(state.entries[0].action).toBe('edit'); + expect(state.entries[1].action).toBe('create'); + }); + + it('trims to maxEntries', () => { + let state = createActivityLog(3); + state = recordActivity(state, 'u1', 'A', 'create', 'd1', 'D', ''); + state = recordActivity(state, 'u1', 'A', 'edit', 'd1', 'D', ''); + state = recordActivity(state, 'u1', 'A', 'comment', 'd1', 'D', ''); + state = recordActivity(state, 'u1', 'A', 'share', 'd1', 'D', ''); + expect(state.entries).toHaveLength(3); + expect(state.entries[0].action).toBe('share'); + }); + + it('generates unique IDs', () => { + let state = createActivityLog(); + state = recordActivity(state, 'u1', 'A', 'create', 'd1', 'D', ''); + state = recordActivity(state, 'u1', 'A', 'edit', 'd1', 'D', ''); + expect(state.entries[0].id).not.toBe(state.entries[1].id); + }); +}); + +describe('filterByAction', () => { + it('returns entries matching action', () => { + const state = buildLog(); + const creates = filterByAction(state, 'create'); + expect(creates).toHaveLength(2); + creates.forEach(e => expect(e.action).toBe('create')); + }); + + it('returns empty for unmatched action', () => { + const state = buildLog(); + expect(filterByAction(state, 'delete')).toEqual([]); + }); +}); + +describe('filterByUser', () => { + it('returns entries for a user', () => { + const state = buildLog(); + const alice = filterByUser(state, 'u1'); + expect(alice).toHaveLength(3); + alice.forEach(e => expect(e.userId).toBe('u1')); + }); +}); + +describe('filterByDocument', () => { + it('returns entries for a document', () => { + const state = buildLog(); + const d1 = filterByDocument(state, 'd1'); + expect(d1).toHaveLength(3); + d1.forEach(e => expect(e.documentId).toBe('d1')); + }); +}); + +describe('filterByTimeRange', () => { + it('returns entries within range', () => { + const state = buildLog(); + const now = Date.now(); + const result = filterByTimeRange(state, now - 1000, now + 1000); + expect(result.length).toBe(5); + }); + + it('returns empty for out-of-range', () => { + const state = buildLog(); + const result = filterByTimeRange(state, 0, 1); + expect(result).toEqual([]); + }); +}); + +describe('paginateEntries', () => { + it('returns correct page', () => { + const state = buildLog(); + const page0 = paginateEntries(state.entries, 0, 2); + expect(page0).toHaveLength(2); + + const page1 = paginateEntries(state.entries, 1, 2); + expect(page1).toHaveLength(2); + + const page2 = paginateEntries(state.entries, 2, 2); + expect(page2).toHaveLength(1); + }); + + it('returns empty for out-of-range page', () => { + const state = buildLog(); + expect(paginateEntries(state.entries, 10, 2)).toEqual([]); + }); +}); + +describe('searchEntries', () => { + it('matches document name', () => { + const state = buildLog(); + const result = searchEntries(state, 'budget'); + expect(result).toHaveLength(3); + }); + + it('matches user name', () => { + const state = buildLog(); + const result = searchEntries(state, 'bob'); + expect(result).toHaveLength(2); + }); + + it('matches details', () => { + const state = buildLog(); + const result = searchEntries(state, 'totals'); + expect(result).toHaveLength(1); + }); + + it('returns all for empty query', () => { + const state = buildLog(); + expect(searchEntries(state, '')).toHaveLength(5); + }); +}); + +describe('countByAction', () => { + it('counts entries per action', () => { + const state = buildLog(); + const counts = countByAction(state); + expect(counts['create']).toBe(2); + expect(counts['edit']).toBe(1); + expect(counts['comment']).toBe(1); + expect(counts['share']).toBe(1); + }); +}); + +describe('countByUser', () => { + it('counts entries per user', () => { + const state = buildLog(); + const counts = countByUser(state); + expect(counts['u1']).toBe(3); + expect(counts['u2']).toBe(2); + }); +}); + +describe('lastActivityForDocument', () => { + it('returns most recent entry for doc', () => { + const state = buildLog(); + const last = lastActivityForDocument(state, 'd1'); + expect(last).not.toBeNull(); + expect(last!.action).toBe('comment'); + }); + + it('returns null for unknown doc', () => { + const state = buildLog(); + expect(lastActivityForDocument(state, 'unknown')).toBeNull(); + }); +}); + +describe('activeDocuments', () => { + it('returns unique doc IDs in order of appearance', () => { + const state = buildLog(); + const docs = activeDocuments(state); + expect(docs).toEqual(['d2', 'd1']); + }); +}); + +describe('clearActivityLog', () => { + it('removes all entries', () => { + const state = buildLog(); + const cleared = clearActivityLog(state); + expect(cleared.entries).toEqual([]); + expect(cleared.maxEntries).toBe(1000); + }); +}); diff --git a/tests/cursor-presence.test.ts b/tests/cursor-presence.test.ts new file mode 100644 index 0000000..c0e9806 --- /dev/null +++ b/tests/cursor-presence.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; +import { + createPresenceState, + assignColor, + updateCursor, + removeUser, + isCursorStale, + getActiveCursors, + getStaleCursors, + pruneStaleUsers, + activeUserCount, + cursorsAtPosition, + cursorLabel, + type UserPresence, +} from '../src/lib/cursor-presence.js'; + +const now = Date.now(); + +describe('createPresenceState', () => { + it('creates state with local user ID', () => { + const state = createPresenceState('me'); + expect(state.localUserId).toBe('me'); + expect(state.users.size).toBe(0); + expect(state.staleThreshold).toBe(30000); + }); + + it('accepts custom stale threshold', () => { + const state = createPresenceState('me', 5000); + expect(state.staleThreshold).toBe(5000); + }); +}); + +describe('assignColor', () => { + it('returns colors from palette', () => { + expect(assignColor(0)).toBe('#e74c3c'); + expect(assignColor(1)).toBe('#3498db'); + }); + + it('wraps around for large indices', () => { + expect(assignColor(8)).toBe(assignColor(0)); + }); +}); + +describe('updateCursor', () => { + it('adds a new remote user cursor', () => { + let state = createPresenceState('me'); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); + expect(state.users.size).toBe(1); + expect(state.users.get('u1')!.userName).toBe('Alice'); + expect(state.users.get('u1')!.cursor!.anchor).toBe('A1'); + }); + + it('ignores local user', () => { + let state = createPresenceState('me'); + state = updateCursor(state, 'me', 'Me', { anchor: 'A1', head: 'A1' }); + expect(state.users.size).toBe(0); + }); + + it('updates existing user cursor', () => { + let state = createPresenceState('me'); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'B2', head: 'B2' }); + expect(state.users.size).toBe(1); + expect(state.users.get('u1')!.cursor!.anchor).toBe('B2'); + }); + + it('preserves existing color on update', () => { + let state = createPresenceState('me'); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }, '#ff0000'); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'B2', head: 'B2' }); + expect(state.users.get('u1')!.color).toBe('#ff0000'); + }); + + it('accepts explicit color', () => { + let state = createPresenceState('me'); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }, '#abc'); + expect(state.users.get('u1')!.color).toBe('#abc'); + }); +}); + +describe('removeUser', () => { + it('removes an existing user', () => { + let state = createPresenceState('me'); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); + state = removeUser(state, 'u1'); + expect(state.users.size).toBe(0); + }); + + it('returns same state if user not found', () => { + const state = createPresenceState('me'); + expect(removeUser(state, 'unknown')).toBe(state); + }); +}); + +describe('isCursorStale', () => { + it('returns false for recent cursor', () => { + const presence: UserPresence = { + userId: 'u1', userName: 'A', color: '#f00', + cursor: { anchor: 'A1', head: 'A1' }, lastSeen: now, + }; + expect(isCursorStale(presence, 30000, now + 1000)).toBe(false); + }); + + it('returns true for old cursor', () => { + const presence: UserPresence = { + userId: 'u1', userName: 'A', color: '#f00', + cursor: { anchor: 'A1', head: 'A1' }, lastSeen: now, + }; + expect(isCursorStale(presence, 30000, now + 60000)).toBe(true); + }); +}); + +describe('getActiveCursors', () => { + it('returns non-stale cursors', () => { + let state = createPresenceState('me', 30000); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); + state = updateCursor(state, 'u2', 'Bob', { anchor: 'B1', head: 'B1' }); + const active = getActiveCursors(state); + expect(active).toHaveLength(2); + }); + + it('excludes stale cursors', () => { + let state = createPresenceState('me', 1000); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); + const active = getActiveCursors(state, Date.now() + 5000); + expect(active).toHaveLength(0); + }); + + it('excludes users with null cursor', () => { + let state = createPresenceState('me'); + const users = new Map(state.users); + users.set('u1', { + userId: 'u1', userName: 'Alice', color: '#f00', + cursor: null, lastSeen: Date.now(), + }); + state = { ...state, users }; + expect(getActiveCursors(state)).toHaveLength(0); + }); +}); + +describe('getStaleCursors', () => { + it('returns stale cursors', () => { + let state = createPresenceState('me', 1000); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); + const stale = getStaleCursors(state, Date.now() + 5000); + expect(stale).toHaveLength(1); + }); +}); + +describe('pruneStaleUsers', () => { + it('removes stale users', () => { + let state = createPresenceState('me', 1000); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); + const pruned = pruneStaleUsers(state, Date.now() + 5000); + expect(pruned.users.size).toBe(0); + }); + + it('returns same state if no stale users', () => { + let state = createPresenceState('me', 30000); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); + expect(pruneStaleUsers(state)).toBe(state); + }); +}); + +describe('activeUserCount', () => { + it('counts active users', () => { + let state = createPresenceState('me'); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); + state = updateCursor(state, 'u2', 'Bob', { anchor: 'B1', head: 'B1' }); + expect(activeUserCount(state)).toBe(2); + }); +}); + +describe('cursorsAtPosition', () => { + it('finds cursors at a specific position', () => { + let state = createPresenceState('me'); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); + state = updateCursor(state, 'u2', 'Bob', { anchor: 'B1', head: 'B1' }); + const at = cursorsAtPosition(state, 'A1'); + expect(at).toHaveLength(1); + expect(at[0].userName).toBe('Alice'); + }); + + it('matches head position too', () => { + let state = createPresenceState('me'); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'C3' }); + expect(cursorsAtPosition(state, 'C3')).toHaveLength(1); + }); + + it('returns empty for no cursors at position', () => { + let state = createPresenceState('me'); + state = updateCursor(state, 'u1', 'Alice', { anchor: 'A1', head: 'A1' }); + expect(cursorsAtPosition(state, 'Z9')).toHaveLength(0); + }); +}); + +describe('cursorLabel', () => { + it('returns user name', () => { + const presence: UserPresence = { + userId: 'u1', userName: 'Alice', color: '#f00', + cursor: { anchor: 'A1', head: 'A1' }, lastSeen: now, + }; + expect(cursorLabel(presence)).toBe('Alice'); + }); +}); diff --git a/tests/timeline-view.test.ts b/tests/timeline-view.test.ts new file mode 100644 index 0000000..c1590d8 --- /dev/null +++ b/tests/timeline-view.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect } from 'vitest'; +import { + parseTimelineDate, + extractTasks, + computeTimelineRange, + dateToPosition, + taskBarLayout, + taskDurationDays, + groupTasksByLane, + sortByStartDate, + filterTasksInRange, + tasksOverlap, + generateDateHeaders, + type TimelineTask, + type TimelineConfig, + type TimelineRange, +} from '../src/sheets/timeline-view.js'; + +const d = (s: string) => new Date(`${s}T12:00:00`); + +const taskA: TimelineTask = { + rowIndex: 1, label: 'Design', startDate: d('2026-03-01'), endDate: d('2026-03-05'), + progress: 100, group: 'Phase 1', +}; +const taskB: TimelineTask = { + rowIndex: 2, label: 'Build', startDate: d('2026-03-04'), endDate: d('2026-03-10'), + progress: 50, group: 'Phase 1', +}; +const taskC: TimelineTask = { + rowIndex: 3, label: 'Test', startDate: d('2026-03-11'), endDate: d('2026-03-15'), + progress: 0, group: 'Phase 2', +}; + +describe('parseTimelineDate', () => { + it('parses date string', () => { + const result = parseTimelineDate('2026-03-15T12:00:00'); + expect(result).toBeInstanceOf(Date); + expect(result!.getDate()).toBe(15); + }); + + it('accepts Date object', () => { + const input = new Date('2026-03-15T12:00:00'); + expect(parseTimelineDate(input)).toBe(input); + }); + + it('accepts timestamp number', () => { + const ts = new Date('2026-03-15T12:00:00').getTime(); + const result = parseTimelineDate(ts); + expect(result!.getDate()).toBe(15); + }); + + it('returns null for empty value', () => { + expect(parseTimelineDate('')).toBeNull(); + expect(parseTimelineDate(null)).toBeNull(); + expect(parseTimelineDate(undefined)).toBeNull(); + }); + + it('returns null for invalid date string', () => { + expect(parseTimelineDate('not-a-date')).toBeNull(); + }); + + it('returns null for invalid Date object', () => { + expect(parseTimelineDate(new Date('invalid'))).toBeNull(); + }); +}); + +describe('extractTasks', () => { + const colToLetter = (c: number) => String.fromCharCode(65 + c); + const config: TimelineConfig = { + labelColumn: 0, startDateColumn: 1, endDateColumn: 2, + progressColumn: 3, groupColumn: 4, + }; + + it('extracts tasks from row data', () => { + const rows: Map[] = [ + new Map([['A1', 'Design'], ['B1', '2026-03-01T12:00:00'], ['C1', '2026-03-05T12:00:00'], ['D1', 80], ['E1', 'Dev']]), + new Map([['A2', 'Test'], ['B2', '2026-03-06T12:00:00'], ['C2', '2026-03-10T12:00:00'], ['D2', 0], ['E2', 'QA']]), + ]; + const tasks = extractTasks(rows, config, colToLetter); + expect(tasks).toHaveLength(2); + expect(tasks[0].label).toBe('Design'); + expect(tasks[0].progress).toBe(80); + expect(tasks[0].group).toBe('Dev'); + expect(tasks[1].label).toBe('Test'); + }); + + it('skips rows with missing dates', () => { + const rows: Map[] = [ + new Map([['A1', 'Design'], ['B1', ''], ['C1', '2026-03-05T12:00:00']]), + ]; + const tasks = extractTasks(rows, { ...config, progressColumn: null, groupColumn: null }, colToLetter); + expect(tasks).toHaveLength(0); + }); + + it('skips rows with missing label', () => { + const rows: Map[] = [ + new Map([['A1', ''], ['B1', '2026-03-01T12:00:00'], ['C1', '2026-03-05T12:00:00']]), + ]; + const tasks = extractTasks(rows, { ...config, progressColumn: null, groupColumn: null }, colToLetter); + expect(tasks).toHaveLength(0); + }); + + it('clamps progress to 0-100', () => { + const rows: Map[] = [ + new Map([['A1', 'X'], ['B1', '2026-03-01T12:00:00'], ['C1', '2026-03-05T12:00:00'], ['D1', 150]]), + ]; + const tasks = extractTasks(rows, { ...config, groupColumn: null }, colToLetter); + expect(tasks[0].progress).toBe(100); + }); +}); + +describe('computeTimelineRange', () => { + it('computes range from tasks', () => { + const range = computeTimelineRange([taskA, taskB, taskC]); + expect(range).not.toBeNull(); + expect(range!.start.getTime()).toBe(taskA.startDate.getTime()); + expect(range!.end.getTime()).toBe(taskC.endDate.getTime()); + expect(range!.totalDays).toBeGreaterThan(0); + }); + + it('returns null for empty tasks', () => { + expect(computeTimelineRange([])).toBeNull(); + }); +}); + +describe('dateToPosition', () => { + it('returns 0 for start date', () => { + const range = computeTimelineRange([taskA, taskC])!; + expect(dateToPosition(range.start, range)).toBe(0); + }); + + it('returns 1 for end date', () => { + const range = computeTimelineRange([taskA, taskC])!; + expect(dateToPosition(range.end, range)).toBe(1); + }); + + it('returns 0.5 for midpoint', () => { + const start = d('2026-03-01'); + const end = d('2026-03-11'); + const range: TimelineRange = { + start, end, totalDays: 11, + }; + const mid = d('2026-03-06'); + expect(dateToPosition(mid, range)).toBeCloseTo(0.5, 1); + }); + + it('clamps to 0-1', () => { + const range = computeTimelineRange([taskA, taskC])!; + const before = new Date(range.start.getTime() - 86400000); + expect(dateToPosition(before, range)).toBe(0); + }); +}); + +describe('taskBarLayout', () => { + it('returns left and width in 0-1 range', () => { + const range = computeTimelineRange([taskA, taskC])!; + const layout = taskBarLayout(taskA, range); + expect(layout.left).toBeGreaterThanOrEqual(0); + expect(layout.width).toBeGreaterThan(0); + expect(layout.left + layout.width).toBeLessThanOrEqual(1); + }); +}); + +describe('taskDurationDays', () => { + it('calculates duration inclusive', () => { + expect(taskDurationDays(taskA)).toBe(5); // Mar 1-5 + }); +}); + +describe('groupTasksByLane', () => { + it('groups by group field', () => { + const lanes = groupTasksByLane([taskA, taskB, taskC]); + expect(lanes.size).toBe(2); + expect(lanes.get('Phase 1')).toHaveLength(2); + expect(lanes.get('Phase 2')).toHaveLength(1); + }); + + it('uses (ungrouped) for empty group', () => { + const ungrouped: TimelineTask = { ...taskA, group: '' }; + const lanes = groupTasksByLane([ungrouped]); + expect(lanes.has('(ungrouped)')).toBe(true); + }); +}); + +describe('sortByStartDate', () => { + it('sorts tasks by start date', () => { + const sorted = sortByStartDate([taskC, taskA, taskB]); + expect(sorted[0].label).toBe('Design'); + expect(sorted[1].label).toBe('Build'); + expect(sorted[2].label).toBe('Test'); + }); +}); + +describe('filterTasksInRange', () => { + it('includes overlapping tasks', () => { + const result = filterTasksInRange([taskA, taskB, taskC], d('2026-03-04'), d('2026-03-06')); + expect(result).toHaveLength(2); // taskA and taskB overlap + }); + + it('excludes non-overlapping tasks', () => { + const result = filterTasksInRange([taskA, taskC], d('2026-03-06'), d('2026-03-08')); + expect(result).toHaveLength(0); + }); +}); + +describe('tasksOverlap', () => { + it('detects overlapping tasks', () => { + expect(tasksOverlap(taskA, taskB)).toBe(true); + }); + + it('detects non-overlapping tasks', () => { + expect(tasksOverlap(taskA, taskC)).toBe(false); + }); +}); + +describe('generateDateHeaders', () => { + it('generates one per day for short range', () => { + const range: TimelineRange = { + start: d('2026-03-01'), end: d('2026-03-03'), totalDays: 3, + }; + const headers = generateDateHeaders(range, 10); + expect(headers).toHaveLength(3); + }); + + it('generates evenly spaced headers for long range', () => { + const range: TimelineRange = { + start: d('2026-03-01'), end: d('2026-03-31'), totalDays: 31, + }; + const headers = generateDateHeaders(range, 5); + expect(headers).toHaveLength(5); + expect(headers[0].getTime()).toBe(range.start.getTime()); + expect(headers[4].getTime()).toBe(range.end.getTime()); + }); + + it('returns empty for maxHeaders 0', () => { + const range: TimelineRange = { + start: d('2026-03-01'), end: d('2026-03-03'), totalDays: 3, + }; + expect(generateDateHeaders(range, 0)).toEqual([]); + }); +});