diff --git a/__tests__/dashboard.test.ts b/__tests__/dashboard.test.ts new file mode 100644 index 0000000..bcf69e4 --- /dev/null +++ b/__tests__/dashboard.test.ts @@ -0,0 +1,293 @@ +import { getDashboardData, hasVisibleBlocks, DashboardNote, DashboardData } from '@/lib/dashboard' + +// Mock prisma and usage before importing dashboard module +jest.mock('@/lib/prisma', () => ({ + prisma: { + note: { + findMany: jest.fn(), + }, + }, +})) + +jest.mock('@/lib/usage', () => ({ + getRecentlyUsedNotes: jest.fn(), +})) + +import { prisma } from '@/lib/prisma' +import { getRecentlyUsedNotes } from '@/lib/usage' + +describe('dashboard.ts', () => { + const mockNotes = [ + { + id: 'note-1', + title: 'Docker Commands', + content: 'docker build, docker run', + type: 'command', + isFavorite: true, + isPinned: false, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-15'), + tags: [{ tag: { id: 'tag-1', name: 'docker' } }], + }, + { + id: 'note-2', + title: 'React Snippet', + content: 'useState usage', + type: 'snippet', + isFavorite: false, + isPinned: true, + createdAt: new Date('2024-01-02'), + updatedAt: new Date('2024-01-14'), + tags: [{ tag: { id: 'tag-2', name: 'react' } }], + }, + { + id: 'note-3', + title: 'Git Commands', + content: 'git commit, git push', + type: 'command', + isFavorite: false, + isPinned: false, + createdAt: new Date('2024-01-03'), + updatedAt: new Date('2024-01-13'), + tags: [{ tag: { id: 'tag-3', name: 'git' } }], + }, + { + id: 'note-4', + title: 'SQL Queries', + content: 'SELECT * FROM', + type: 'snippet', + isFavorite: true, + isPinned: true, + createdAt: new Date('2024-01-04'), + updatedAt: new Date('2024-01-12'), + tags: [{ tag: { id: 'tag-4', name: 'sql' } }], + }, + ] + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('getDashboardData', () => { + it('should return dashboard data with all sections', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([ + { noteId: 'note-1', count: 5, lastUsed: new Date() }, + { noteId: 'note-2', count: 3, lastUsed: new Date() }, + ]) + + const result = await getDashboardData(3) + + expect(result).toHaveProperty('recentNotes') + expect(result).toHaveProperty('mostUsedNotes') + expect(result).toHaveProperty('recentCommands') + expect(result).toHaveProperty('recentSnippets') + expect(result).toHaveProperty('activityBasedNotes') + expect(result).toHaveProperty('hasActivity') + }) + + it('should return recent notes ordered by updatedAt', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([]) + + const result = await getDashboardData(2) + + expect(result.recentNotes).toHaveLength(2) + expect(result.recentNotes[0].id).toBe('note-1') // most recent + expect(result.recentNotes[1].id).toBe('note-2') + }) + + it('should return most used notes by usage count', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([ + { noteId: 'note-2', count: 10, lastUsed: new Date() }, + { noteId: 'note-1', count: 5, lastUsed: new Date() }, + ]) + + const result = await getDashboardData(3) + + expect(result.mostUsedNotes).toHaveLength(2) + expect(result.mostUsedNotes[0].id).toBe('note-2') // highest usage + expect(result.mostUsedNotes[0].usageCount).toBe(10) + }) + + it('should return only command type notes for recentCommands', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([]) + + const result = await getDashboardData(5) + + expect(result.recentCommands).toHaveLength(2) + expect(result.recentCommands.every((n: DashboardNote) => n.type === 'command')).toBe(true) + expect(result.recentCommands[0].id).toBe('note-1') + }) + + it('should return only snippet type notes for recentSnippets', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([]) + + const result = await getDashboardData(5) + + expect(result.recentSnippets).toHaveLength(2) + expect(result.recentSnippets.every((n: DashboardNote) => n.type === 'snippet')).toBe(true) + }) + + it('should return activity based notes excluding recent notes', async () => { + // mockNotes are sorted by updatedAt desc: note-1, note-2, note-3, note-4 + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + // note-4 is in recently used but will be filtered out since recentNotes has first 3 + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([ + { noteId: 'note-4', count: 5, lastUsed: new Date() }, + ]) + + const result = await getDashboardData(3) + + // note-4 is used but not in recentNotes (which contains note-1, note-2, note-3) + expect(result.activityBasedNotes).toHaveLength(1) + expect(result.activityBasedNotes[0].id).toBe('note-4') + }) + + it('should set hasActivity based on recently used notes', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([ + { noteId: 'note-1', count: 5, lastUsed: new Date() }, + ]) + + const resultWithActivity = await getDashboardData(3) + expect(resultWithActivity.hasActivity).toBe(true) + + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([]) + const resultWithoutActivity = await getDashboardData(3) + expect(resultWithoutActivity.hasActivity).toBe(false) + }) + + it('should respect the limit parameter', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([ + { noteId: 'note-1', count: 10, lastUsed: new Date() }, + { noteId: 'note-2', count: 8, lastUsed: new Date() }, + { noteId: 'note-3', count: 6, lastUsed: new Date() }, + { noteId: 'note-4', count: 4, lastUsed: new Date() }, + ]) + + const result = await getDashboardData(2) + + expect(result.recentNotes).toHaveLength(2) + expect(result.mostUsedNotes).toHaveLength(2) + }) + + it('should add usageCount to notes from usage map', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([ + { noteId: 'note-1', count: 10, lastUsed: new Date() }, + { noteId: 'note-2', count: 5, lastUsed: new Date() }, + ]) + + const result = await getDashboardData(3) + + const note1 = result.mostUsedNotes.find((n: DashboardNote) => n.id === 'note-1') + const note2 = result.mostUsedNotes.find((n: DashboardNote) => n.id === 'note-2') + expect(note1?.usageCount).toBe(10) + expect(note2?.usageCount).toBe(5) + }) + + it('should return empty arrays when no notes exist', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([]) + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([]) + + const result = await getDashboardData() + + expect(result.recentNotes).toEqual([]) + expect(result.mostUsedNotes).toEqual([]) + expect(result.recentCommands).toEqual([]) + expect(result.recentSnippets).toEqual([]) + expect(result.activityBasedNotes).toEqual([]) + expect(result.hasActivity).toBe(false) + }) + + it('should convert dates to ISO strings', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + ;(getRecentlyUsedNotes as jest.Mock).mockResolvedValue([]) + + const result = await getDashboardData(1) + + expect(result.recentNotes[0].createdAt).toBe('2024-01-01T00:00:00.000Z') + expect(result.recentNotes[0].updatedAt).toBe('2024-01-15T00:00:00.000Z') + }) + }) + + describe('hasVisibleBlocks', () => { + it('should return true when recentNotes has items', () => { + const data: DashboardData = { + recentNotes: [{ id: '1', title: 'Test', content: '', type: 'note' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }], + mostUsedNotes: [], + recentCommands: [], + recentSnippets: [], + activityBasedNotes: [], + hasActivity: false, + } + expect(hasVisibleBlocks(data)).toBe(true) + }) + + it('should return true when mostUsedNotes has items', () => { + const data: DashboardData = { + recentNotes: [], + mostUsedNotes: [{ id: '1', title: 'Test', content: '', type: 'note' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [], usageCount: 5 }], + recentCommands: [], + recentSnippets: [], + activityBasedNotes: [], + hasActivity: false, + } + expect(hasVisibleBlocks(data)).toBe(true) + }) + + it('should return true when recentCommands has items', () => { + const data: DashboardData = { + recentNotes: [], + mostUsedNotes: [], + recentCommands: [{ id: '1', title: 'Test', content: '', type: 'command' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }], + recentSnippets: [], + activityBasedNotes: [], + hasActivity: false, + } + expect(hasVisibleBlocks(data)).toBe(true) + }) + + it('should return true when recentSnippets has items', () => { + const data: DashboardData = { + recentNotes: [], + mostUsedNotes: [], + recentCommands: [], + recentSnippets: [{ id: '1', title: 'Test', content: '', type: 'snippet' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }], + activityBasedNotes: [], + hasActivity: false, + } + expect(hasVisibleBlocks(data)).toBe(true) + }) + + it('should return true when activityBasedNotes has items', () => { + const data: DashboardData = { + recentNotes: [], + mostUsedNotes: [], + recentCommands: [], + recentSnippets: [], + activityBasedNotes: [{ id: '1', title: 'Test', content: '', type: 'note' as const, isFavorite: false, isPinned: false, createdAt: '', updatedAt: '', tags: [] }], + hasActivity: true, + } + expect(hasVisibleBlocks(data)).toBe(true) + }) + + it('should return false when all arrays are empty', () => { + const data: DashboardData = { + recentNotes: [], + mostUsedNotes: [], + recentCommands: [], + recentSnippets: [], + activityBasedNotes: [], + hasActivity: false, + } + expect(hasVisibleBlocks(data)).toBe(false) + }) + }) +}) diff --git a/__tests__/related.test.ts b/__tests__/related.test.ts new file mode 100644 index 0000000..fb1bbdd --- /dev/null +++ b/__tests__/related.test.ts @@ -0,0 +1,246 @@ +import { getRelatedNotes } from '@/lib/related' + +// Mock prisma and usage before importing related module +jest.mock('@/lib/prisma', () => ({ + prisma: { + note: { + findUnique: jest.fn(), + findMany: jest.fn(), + }, + }, +})) + +jest.mock('@/lib/usage', () => ({ + getUsageStats: jest.fn(), +})) + +import { prisma } from '@/lib/prisma' +import { getUsageStats } from '@/lib/usage' + +describe('related.ts', () => { + const createMockNote = (overrides = {}) => ({ + id: 'note-1', + title: 'Docker Commands', + content: 'docker build and docker run commands', + type: 'command', + isFavorite: false, + isPinned: false, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-15'), + tags: [ + { tag: { id: 'tag-1', name: 'docker' } }, + { tag: { id: 'tag-2', name: 'containers' } }, + ], + ...overrides, + }) + + const createMockOtherNote = (overrides = {}) => ({ + id: 'note-other', + title: 'Other Note Title', + content: 'other content xyz123', + type: 'snippet', + tags: [] as { tag: { id: string; name: string } }[], + ...overrides, + }) + + beforeEach(() => { + jest.clearAllMocks() + // Default usage stats - no usage + ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 }) + }) + + describe('getRelatedNotes', () => { + it('should return empty array when note not found', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null) + + const result = await getRelatedNotes('non-existent') + + expect(result).toEqual([]) + }) + + it('should return empty array when no related notes found', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ id: 'note-x', title: 'Completely Different', content: 'xyz abc def' }), + ]) + + const result = await getRelatedNotes('note-1') + + expect(result).toEqual([]) + }) + + it('should return related notes sorted by score', async () => { + const mockNote = createMockNote() + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker Compose', tags: [{ tag: { id: 't1', name: 'docker' } }] }), + createMockOtherNote({ id: 'note-3', title: 'React Hooks', content: 'react hooks' }), + ]) + + const result = await getRelatedNotes('note-1') + + expect(result.length).toBeGreaterThan(0) + // First result should have highest score + for (let i = 0; i < result.length - 1; i++) { + expect(result[i].score).toBeGreaterThanOrEqual(result[i + 1].score) + } + }) + + it('should give +3 for same type', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different' }), + ]) + + const result = await getRelatedNotes('note-1') + + const note2 = result.find((n) => n.id === 'note-2') + expect(note2).toBeDefined() + expect(note2!.score).toBe(3) // only type match + expect(note2!.reason).toContain('Same type') + }) + + it('should give +3 per shared tag', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ + id: 'note-2', + type: 'snippet', // different type to isolate tag scoring + title: 'XYZ Title', + content: 'different content', + tags: [ + { tag: { id: 'tag-1', name: 'docker' } }, + { tag: { id: 'tag-2', name: 'containers' } }, + ], + }), + ]) + + const result = await getRelatedNotes('note-1') + + const note2 = result.find((n) => n.id === 'note-2') + expect(note2).toBeDefined() + expect(note2!.score).toBe(6) // 2 tags * 3 = 6 + expect(note2!.reason).toContain('Tags: docker, containers') + }) + + it('should cap title keyword boost at +3', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote({ title: 'Docker Kubernetes', content: 'different' })) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ + id: 'note-2', + type: 'snippet', + title: 'Docker Kubernetes Python Ruby', // 4 shared keywords but capped at 3 + content: 'different', + tags: [], + }), + ]) + + const result = await getRelatedNotes('note-1') + + const note2 = result.find((n) => n.id === 'note-2') + expect(note2).toBeDefined() + // Type mismatch = 0, Tags = 0, Title keywords capped at +3 + expect(note2!.score).toBeLessThanOrEqual(3) + }) + + it('should cap content keyword boost at +2', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote({ title: 'Different', content: 'docker kubernetes python ruby' })) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ + id: 'note-2', + type: 'snippet', + title: 'Title', + content: 'docker kubernetes python', // 3 shared but capped at 2 + tags: [], + }), + ]) + + const result = await getRelatedNotes('note-1') + + const note2 = result.find((n) => n.id === 'note-2') + expect(note2).toBeDefined() + }) + + it('should add usage-based boost with view count', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }), + ]) + ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 15, clicks: 0, relatedClicks: 0 }) + + const result = await getRelatedNotes('note-1') + + const note2 = result.find((n) => n.id === 'note-2') + // Base: 3 (type) + floor(15/5)=3 + 2 (recency) = 8 + expect(note2!.score).toBe(8) + }) + + it('should cap usage view boost at +3', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }), + ]) + ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 50, clicks: 0, relatedClicks: 0 }) + + const result = await getRelatedNotes('note-1') + + const note2 = result.find((n) => n.id === 'note-2') + // Base: 3 (type) + min(floor(50/5),3)=3 + 2 (recency) = 8 + expect(note2!.score).toBe(8) + }) + + it('should add +2 recency boost when note has any views', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }), + ]) + ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 5, clicks: 0, relatedClicks: 0 }) + + const result = await getRelatedNotes('note-1') + + const note2 = result.find((n) => n.id === 'note-2') + // Base: 3 (type) + floor(5/5)=1 + 2 (recency) = 6 + expect(note2!.score).toBe(6) + }) + + it('should use relatedClicks for recency boost', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }), + ]) + ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 3 }) + + const result = await getRelatedNotes('note-1') + + const note2 = result.find((n) => n.id === 'note-2') + // Base: 3 (type) + 0 (views) + 2 (related clicks recency) = 5 + expect(note2!.score).toBe(5) + }) + + it('should respect limit parameter', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker', tags: [{ tag: { id: 't1', name: 'docker' } }] }), + createMockOtherNote({ id: 'note-3', type: 'command', title: 'Kubernetes', tags: [{ tag: { id: 't2', name: 'kubernetes' } }] }), + createMockOtherNote({ id: 'note-4', type: 'command', title: 'Git', tags: [{ tag: { id: 't3', name: 'git' } }] }), + ]) + + const result = await getRelatedNotes('note-1', 2) + + expect(result).toHaveLength(2) + }) + + it('should include reason field', async () => { + ;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote()) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker', tags: [{ tag: { id: 't1', name: 'docker' } }] }), + ]) + + const result = await getRelatedNotes('note-1') + + expect(result[0].reason).toBeDefined() + expect(typeof result[0].reason).toBe('string') + expect(result[0].reason.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/__tests__/search.test.ts b/__tests__/search.test.ts index 9082cf4..df6bfef 100644 --- a/__tests__/search.test.ts +++ b/__tests__/search.test.ts @@ -1,15 +1,64 @@ -import { highlightMatches } from '@/lib/search' +import { highlightMatches, noteQuery, searchNotes, ScoredNote } from '@/lib/search' -// Mock prisma before importing search module +// Mock prisma and usage before importing search module jest.mock('@/lib/prisma', () => ({ prisma: { note: { - findMany: jest.fn().mockResolvedValue([]), + findMany: jest.fn(), }, }, })) +jest.mock('@/lib/usage', () => ({ + getUsageStats: jest.fn(), +})) + +import { prisma } from '@/lib/prisma' +import { getUsageStats } from '@/lib/usage' + describe('search.ts', () => { + const mockNotes = [ + { + id: 'note-1', + title: 'Docker Commands', + content: 'docker build and docker run', + type: 'command', + isFavorite: true, + isPinned: false, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-15'), + tags: [{ tag: { id: 'tag-1', name: 'docker' } }], + }, + { + id: 'note-2', + title: 'React Hooks', + content: 'useState and useEffect hooks', + type: 'snippet', + isFavorite: false, + isPinned: true, + createdAt: new Date('2024-01-02'), + updatedAt: new Date('2024-01-10'), + tags: [{ tag: { id: 'tag-2', name: 'react' } }], + }, + { + id: 'note-3', + title: 'Git Commands', + content: 'git commit and git push', + type: 'command', + isFavorite: false, + isPinned: false, + createdAt: new Date('2024-01-03'), + updatedAt: new Date('2024-01-05'), + tags: [{ tag: { id: 'tag-3', name: 'git' } }], + }, + ] + + beforeEach(() => { + jest.clearAllMocks() + // Default: no usage + ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 }) + }) + describe('highlightMatches', () => { it('returns first 150 characters when query is empty', () => { const text = 'This is a long text that should be truncated to 150 characters. ' + @@ -40,7 +89,6 @@ describe('search.ts', () => { it('adds ellipsis when match is not at start', () => { const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. JavaScript is great.' const result = highlightMatches(text, 'JavaScript') - // JavaScript is at position 61, start is max(0, 61-75) = 0, so no ellipsis needed expect(result).toContain('JavaScript') }) @@ -56,8 +104,197 @@ describe('search.ts', () => { // Single char words (A, B, C) are filtered, returns 150 chars expect(result.length).toBeLessThanOrEqual(150) }) + + it('adds ellipsis when match is far from start', () => { + // JavaScript is at position ~26, start = max(0, 26-75) = 0, so no ellipsis needed + // We need a longer text where match is more than 75 chars from start + const text = 'Lorem ipsum dolor sit amet. '.repeat(10) + 'JavaScript programming language.' + const result = highlightMatches(text, 'JavaScript') + expect(result).toContain('...') + }) }) - // Integration tests would go here but require database setup - // These would test noteQuery and searchNotes with actual data + describe('noteQuery', () => { + it('returns empty array when no notes exist', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([]) + + const result = await noteQuery('docker') + + expect(result).toEqual([]) + }) + + it('returns notes with exact title match scored highest', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await noteQuery('docker') + + expect(result[0].id).toBe('note-1') // exact title match + expect(result[0].matchType).toBe('exact') + }) + + it('returns notes with content match', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await noteQuery('hooks') + + expect(result.length).toBeGreaterThan(0) + expect(result.some((n: ScoredNote) => n.id === 'note-2')).toBe(true) + }) + + it('returns fuzzy matches when no exact match', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await noteQuery('docer') // typo + + expect(result.length).toBeGreaterThan(0) + expect(result[0].matchType).toBe('fuzzy') + }) + + it('excludes notes with no match (low similarity)', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await noteQuery('xyz123nonexistent') + + expect(result.length).toBe(0) + }) + + it('adds +2 for favorite notes', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await noteQuery('docker') + + // note-1 is favorite, should have higher score + const dockerNote = result.find((n: ScoredNote) => n.id === 'note-1') + expect(dockerNote?.isFavorite).toBe(true) + }) + + it('adds +1 for pinned notes', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await noteQuery('hooks') + + // note-2 is pinned + const hooksNote = result.find((n: ScoredNote) => n.id === 'note-2') + expect(hooksNote?.isPinned).toBe(true) + }) + + it('adds +1 for recently updated notes (within 7 days)', async () => { + const recentNote = { + ...mockNotes[0], + updatedAt: new Date(Date.now() - 1000), // just now + } + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([recentNote]) + + const result = await noteQuery('docker') + + expect(result[0].score).toBeGreaterThan(0) + }) + + it('filters by type when specified', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await noteQuery('', { type: 'command' }) + + expect(result.every((n: ScoredNote) => n.type === 'command')).toBe(true) + }) + + it('filters by tag when specified', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await noteQuery('', { tag: 'docker' }) + + expect(result.length).toBe(1) + expect(result[0].id).toBe('note-1') + }) + + it('returns notes sorted by score descending', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await noteQuery('') + + for (let i = 0; i < result.length - 1; i++) { + expect(result[i].score).toBeGreaterThanOrEqual(result[i + 1].score) + } + }) + + it('returns highlight excerpt for matched notes', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await noteQuery('docker') + + expect(result[0].highlight).toBeDefined() + expect(typeof result[0].highlight).toBe('string') + }) + }) + + describe('searchNotes', () => { + it('should be an alias for noteQuery', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await searchNotes('docker') + + expect(result).toEqual(await noteQuery('docker')) + }) + + it('passes filters to noteQuery', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes) + + const result = await searchNotes('', { type: 'snippet' }) + + expect(result.every((n: ScoredNote) => n.type === 'snippet')).toBe(true) + }) + }) + + describe('usage-based scoring boost', () => { + it('calls getUsageStats for each note', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]]) + ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 }) + + await noteQuery('docker') + + expect(getUsageStats).toHaveBeenCalledWith('note-1', 7) + }) + + it('handles getUsageStats returning zero values', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]]) + ;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 }) + + const result = await noteQuery('docker') + + // Should return results without error + expect(result).toBeDefined() + expect(result.length).toBeGreaterThan(0) + }) + }) + + describe('ScoredNote interface', () => { + it('returns correct structure for scored notes', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]]) + + const result = await noteQuery('docker') + + expect(result[0]).toHaveProperty('id') + expect(result[0]).toHaveProperty('title') + expect(result[0]).toHaveProperty('content') + expect(result[0]).toHaveProperty('type') + expect(result[0]).toHaveProperty('isFavorite') + expect(result[0]).toHaveProperty('isPinned') + expect(result[0]).toHaveProperty('createdAt') + expect(result[0]).toHaveProperty('updatedAt') + expect(result[0]).toHaveProperty('tags') + expect(result[0]).toHaveProperty('score') + expect(result[0]).toHaveProperty('highlight') + expect(result[0]).toHaveProperty('matchType') + expect(['exact', 'fuzzy']).toContain(result[0].matchType) + }) + + it('converts Date objects to ISO strings', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]]) + + const result = await noteQuery('docker') + + expect(result[0].createdAt).toBe('2024-01-01T00:00:00.000Z') + expect(result[0].updatedAt).toBe('2024-01-15T00:00:00.000Z') + }) + }) }) diff --git a/__tests__/usage.test.ts b/__tests__/usage.test.ts new file mode 100644 index 0000000..8ae2baa --- /dev/null +++ b/__tests__/usage.test.ts @@ -0,0 +1,223 @@ +import { trackNoteUsage, getNoteUsageCount, getRecentlyUsedNotes, getUsageStats, UsageEventType } from '@/lib/usage' + +// Mock prisma before importing usage module +jest.mock('@/lib/prisma', () => ({ + prisma: { + noteUsage: { + create: jest.fn(), + count: jest.fn(), + groupBy: jest.fn(), + }, + note: { + findMany: jest.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' + +describe('usage.ts', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('trackNoteUsage', () => { + it('should create a usage event with all fields', async () => { + ;(prisma.noteUsage.create as jest.Mock).mockResolvedValue({ id: '1' }) + + await trackNoteUsage({ + noteId: 'note-1', + eventType: 'view', + query: 'docker commands', + metadata: { source: 'search' }, + }) + + expect(prisma.noteUsage.create).toHaveBeenCalledWith({ + data: { + noteId: 'note-1', + eventType: 'view', + query: 'docker commands', + metadata: JSON.stringify({ source: 'search' }), + }, + }) + }) + + it('should create a usage event without optional fields', async () => { + ;(prisma.noteUsage.create as jest.Mock).mockResolvedValue({ id: '1' }) + + await trackNoteUsage({ + noteId: 'note-1', + eventType: 'view', + }) + + expect(prisma.noteUsage.create).toHaveBeenCalledWith({ + data: { + noteId: 'note-1', + eventType: 'view', + query: null, + metadata: null, + }, + }) + }) + + it('should silently fail on database error', async () => { + ;(prisma.noteUsage.create as jest.Mock).mockRejectedValue(new Error('DB error')) + + // Should not throw + await expect( + trackNoteUsage({ noteId: 'note-1', eventType: 'view' }) + ).resolves.not.toThrow() + }) + }) + + describe('getNoteUsageCount', () => { + it('should return count for a specific note', async () => { + ;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(5) + + const result = await getNoteUsageCount('note-1') + + expect(result).toBe(5) + expect(prisma.noteUsage.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ noteId: 'note-1' }), + }) + }) + + it('should filter by event type when specified', async () => { + ;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(3) + + const result = await getNoteUsageCount('note-1', 'search_click') + + expect(result).toBe(3) + expect(prisma.noteUsage.count).toHaveBeenCalledWith({ + where: expect.objectContaining({ + noteId: 'note-1', + eventType: 'search_click', + }), + }) + }) + + it('should apply default 30 days filter', async () => { + ;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(0) + + await getNoteUsageCount('note-1') + + const call = (prisma.noteUsage.count as jest.Mock).mock.calls[0][0] + expect(call.where.createdAt.gte).toBeInstanceOf(Date) + }) + + it('should return 0 on database error', async () => { + ;(prisma.noteUsage.count as jest.Mock).mockRejectedValue(new Error('DB error')) + + const result = await getNoteUsageCount('note-1') + + expect(result).toBe(0) + }) + }) + + describe('getRecentlyUsedNotes', () => { + it('should return recently used notes with counts', async () => { + const mockGroupBy = [ + { noteId: 'note-1', _count: { id: 10 } }, + { noteId: 'note-2', _count: { id: 5 } }, + ] + ;(prisma.noteUsage.groupBy as jest.Mock).mockResolvedValue(mockGroupBy) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + { id: 'note-1', updatedAt: new Date('2024-01-15') }, + { id: 'note-2', updatedAt: new Date('2024-01-14') }, + ]) + + const result = await getRecentlyUsedNotes('user-1', 10, 30) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + noteId: 'note-1', + count: 10, + lastUsed: expect.any(Date), + }) + }) + + it('should return empty array on database error', async () => { + ;(prisma.noteUsage.groupBy as jest.Mock).mockRejectedValue(new Error('DB error')) + + const result = await getRecentlyUsedNotes('user-1') + + expect(result).toEqual([]) + }) + + it('should respect limit parameter', async () => { + ;(prisma.noteUsage.groupBy as jest.Mock).mockResolvedValue([]) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([]) + + await getRecentlyUsedNotes('user-1', 5) + + expect(prisma.noteUsage.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ take: 5 }) + ) + }) + }) + + describe('getUsageStats', () => { + it('should return usage stats for a note', async () => { + ;(prisma.noteUsage.count as jest.Mock) + .mockResolvedValueOnce(15) // views + .mockResolvedValueOnce(8) // clicks + .mockResolvedValueOnce(3) // relatedClicks + + const result = await getUsageStats('note-1', 30) + + expect(result).toEqual({ + views: 15, + clicks: 8, + relatedClicks: 3, + }) + }) + + it('should return zeros on database error', async () => { + ;(prisma.noteUsage.count as jest.Mock).mockRejectedValue(new Error('DB error')) + + const result = await getUsageStats('note-1') + + expect(result).toEqual({ + views: 0, + clicks: 0, + relatedClicks: 0, + }) + }) + + it('should query with correct date filter', async () => { + ;(prisma.noteUsage.count as jest.Mock).mockResolvedValue(0) + + await getUsageStats('note-1', 7) + + // All three counts should use the same date filter + for (const call of (prisma.noteUsage.count as jest.Mock).mock.calls) { + expect(call[0].where.createdAt.gte).toBeInstanceOf(Date) + } + }) + + it('should return zeros when any count fails', async () => { + // Promise.all will reject on first failure, so we can't get partial results + ;(prisma.noteUsage.count as jest.Mock) + .mockResolvedValueOnce(10) // views + .mockRejectedValueOnce(new Error('DB error')) // clicks fails + .mockResolvedValueOnce(5) // relatedClicks never called + + const result = await getUsageStats('note-1') + + // All zeros because the whole operation fails + expect(result.views).toBe(0) + expect(result.clicks).toBe(0) + expect(result.relatedClicks).toBe(0) + }) + }) + + describe('UsageEventType', () => { + it('should accept all valid event types', () => { + const eventTypes: UsageEventType[] = ['view', 'search_click', 'related_click', 'link_click'] + + eventTypes.forEach((eventType) => { + expect(['view', 'search_click', 'related_click', 'link_click']).toContain(eventType) + }) + }) + }) +}) diff --git a/backlog/recall-mvp3-tickets-claude-code.md b/backlog/recall-mvp3-tickets-claude-code.md new file mode 100644 index 0000000..fc1f2a3 --- /dev/null +++ b/backlog/recall-mvp3-tickets-claude-code.md @@ -0,0 +1,581 @@ +# Recall — Tickets técnicos MVP-3 para Claude Code + +## Objetivo general +Convertir Recall en una herramienta indispensable de uso diario, enfocada en: +- recuperación pasiva +- ranking basado en uso real +- sugerencias automáticas +- mapa de conocimiento simple +- reducción adicional de fricción en captura + +--- + +# EPIC 1 — Dashboard y uso diario + +## [P1] Ticket 01 — Rediseñar dashboard para valor inmediato + +**Objetivo** +Hacer que la pantalla inicial devuelva valor sin necesidad de buscar. + +**Contexto** +Recall ya soporta búsqueda avanzada, quick add, backlinks, notas relacionadas, tags y UX especializada por tipo. El siguiente paso es que el home priorice descubrimiento y reutilización. + +**Alcance** +- Reemplazar dashboard actual por bloques orientados a uso: + - notas recientes + - notas más usadas + - comandos recientes + - snippets recientes + - sugerencias relacionadas a la actividad reciente +- Crear endpoint o función de agregación para dashboard +- Ordenar visualmente por relevancia, no solo por fecha + +**No incluye** +- métricas históricas avanzadas +- personalización por usuario + +**Criterios de aceptación** +- Al abrir `/`, se ven al menos 4 bloques útiles +- “Más usadas” no depende solo de `updatedAt` +- Los bloques no rompen con base vacía +- El dashboard responde correctamente con 0, pocas o muchas notas + +**Archivos sugeridos** +- `src/app/page.tsx` +- `src/components/dashboard.tsx` +- `src/lib/dashboard.ts` +- `src/app/api/dashboard/route.ts` (opcional) + +**Notas técnicas** +- Reutilizar scoring y modelos existentes +- Mantener SSR o server components donde tenga sentido + +--- + +## [P1] Ticket 02 — Registrar eventos de uso de notas + +**Objetivo** +Capturar señales reales de uso para mejorar ranking y sugerencias. + +**Alcance** +- Crear modelo de uso o contador agregado +- Registrar eventos mínimos: + - apertura de nota + - copia de comando + - copia de snippet + - uso desde quick add relacionado +- Exponer utilidades para incrementar métricas + +**No incluye** +- tracking externo +- analytics de terceros + +**Criterios de aceptación** +- Abrir una nota incrementa su contador de uso +- Copiar contenido especializado suma señal adicional +- La captura falla de forma segura sin romper la UI +- Se puede consultar el uso agregado por nota + +**Archivos sugeridos** +- `prisma/schema.prisma` +- `src/lib/usage.ts` +- `src/app/api/notes/[id]/usage/route.ts` +- `src/components/note-card.tsx` +- `src/components/markdown-content.tsx` + +**Notas técnicas** +- Preferir modelo simple: + - `NoteUsage` por evento o + - campos agregados en `Note` +- Si eliges eventos, agregar tarea/helper de agregación futura + +--- + +## [P1] Ticket 03 — Mostrar “según tu actividad reciente” + +**Objetivo** +Crear recuperación pasiva basada en uso reciente. + +**Alcance** +- Detectar últimas notas abiertas/usadas +- Sugerir notas relacionadas en dashboard +- Crear bloque “Según tu actividad reciente” + +**Criterios de aceptación** +- Si hay actividad reciente, aparecen sugerencias relevantes +- Si no hay actividad, el bloque se oculta o usa fallback +- El bloque muestra por qué se recomienda una nota + +**Archivos sugeridos** +- `src/lib/dashboard.ts` +- `src/components/dashboard.tsx` +- `src/lib/related.ts` +- `src/lib/usage.ts` + +--- + +# EPIC 2 — Ranking inteligente y recuperación pasiva + +## [P1] Ticket 04 — Extender scoring con señales de uso real + +**Objetivo** +Mejorar la relevancia de búsqueda y recomendaciones con comportamiento real. + +**Alcance** +- Extender `search.ts` y/o `related.ts` para incluir: + - cantidad de aperturas + - copias + - recencia de uso + - frecuencia de uso +- Ajustar pesos de scoring + +**No incluye** +- machine learning +- embeddings + +**Criterios de aceptación** +- Una nota muy usada sube en empates razonables +- El ranking sigue priorizando match textual fuerte +- El score es explicable y testeable + +**Archivos sugeridos** +- `src/lib/search.ts` +- `src/lib/related.ts` +- `src/lib/usage.ts` +- `__tests__/search.test.ts` +- `__tests__/related.test.ts` + +**Notas técnicas** +- No permitir que uso alto eclipse resultados textualmente irrelevantes +- Mantener scoring determinístico + +--- + +## [P1] Ticket 05 — Crear recomendaciones pasivas en detalle de nota + +**Objetivo** +Que una nota devuelva otras útiles sin necesidad de nueva búsqueda. + +**Alcance** +- En vista de detalle agregar bloque: + - “También podrías necesitar” +- Basar sugerencias en: + - backlinks + - related score + - uso conjunto + - tags compartidos + +**Criterios de aceptación** +- El bloque aparece en detalle de nota +- Muestra entre 3 y 6 sugerencias +- Muestra razón resumida de recomendación +- Excluye nota actual y duplicados + +**Archivos sugeridos** +- `src/app/notes/[id]/page.tsx` +- `src/components/related-notes.tsx` +- `src/lib/recommendations.ts` + +--- + +## [P2] Ticket 06 — Registrar co-uso entre notas + +**Objetivo** +Detectar notas que suelen usarse juntas. + +**Alcance** +- Al abrir una nota, registrar relación con notas abiertas recientemente +- Generar señal de co-uso +- Exponer helper para recomendar “suelen usarse juntas” + +**Criterios de aceptación** +- El sistema puede devolver notas co-usadas +- No hay duplicados ni relaciones simétricas inconsistentes +- La implementación escala razonablemente para dataset pequeño/medio + +**Archivos sugeridos** +- `prisma/schema.prisma` +- `src/lib/co-usage.ts` +- `src/lib/recommendations.ts` + +--- + +# EPIC 3 — Sugerencias automáticas y enriquecimiento + +## [P1] Ticket 07 — Sugerir tags automáticamente al escribir + +**Objetivo** +Reducir esfuerzo manual en clasificación. + +**Alcance** +- Analizar título y contenido del formulario +- Sugerir tags existentes según: + - coincidencias de términos + - frecuencia histórica + - tipo de nota +- Mostrar sugerencias no invasivas + +**Criterios de aceptación** +- Al escribir contenido aparecen sugerencias útiles +- El usuario puede aceptar o ignorar sugerencias +- No se agregan tags automáticamente sin acción del usuario +- Funciona con debounce + +**Archivos sugeridos** +- `src/app/api/tags/suggest/route.ts` +- `src/lib/tags.ts` +- `src/components/note-form.tsx` + +**Notas técnicas** +- Priorizar tags existentes para evitar proliferación innecesaria + +--- + +## [P2] Ticket 08 — Sugerir tipo de nota automáticamente + +**Objetivo** +Acelerar creación cuando el usuario pega contenido ambiguo. + +**Alcance** +- Detectar patrones de contenido para sugerir tipo: + - bloque de código → `snippet` + - comando shell → `command` + - lista de pasos → `procedure` + - ingredientes/pasos → `recipe` + - contexto/decisión → `decision` +- Mostrar recomendación editable + +**Criterios de aceptación** +- El formulario propone un tipo probable +- El usuario puede mantener o cambiar el tipo +- No sobrescribe tipo si el usuario ya eligió uno manualmente + +**Archivos sugeridos** +- `src/lib/type-inference.ts` +- `src/components/note-form.tsx` +- `__tests__/type-inference.test.ts` + +--- + +## [P2] Ticket 09 — Sugerir links internos mientras se escribe + +**Objetivo** +Fortalecer la red de conocimiento sin depender de memoria del usuario. + +**Alcance** +- Analizar contenido y detectar posibles referencias a notas existentes +- Sugerir convertir términos en `[[nota]]` +- Permitir inserción con un click + +**Criterios de aceptación** +- El sistema detecta coincidencias plausibles con títulos existentes +- El usuario puede insertar el link sugerido +- No genera links automáticos sin confirmación + +**Archivos sugeridos** +- `src/lib/backlinks.ts` +- `src/lib/link-suggestions.ts` +- `src/components/note-form.tsx` + +--- + +# EPIC 4 — Mapa de conocimiento simple + +## [P1] Ticket 10 — Crear panel “Conectado con” + +**Objetivo** +Dar una vista de contexto inmediata sin construir un grafo complejo. + +**Alcance** +- En detalle de nota, agregar panel lateral o bloque: + - backlinks + - links salientes + - relacionadas + - co-usadas +- Agrupar visualmente cada tipo de relación + +**Criterios de aceptación** +- La nota muestra claramente su red local +- Se distinguen tipos de conexión +- Los enlaces navegan correctamente +- Con 0 conexiones el bloque no se rompe + +**Archivos sugeridos** +- `src/app/notes/[id]/page.tsx` +- `src/components/note-connections.tsx` +- `src/lib/backlinks.ts` +- `src/lib/related.ts` +- `src/lib/recommendations.ts` + +--- + +## [P2] Ticket 11 — Identificar notas centrales + +**Objetivo** +Detectar nodos importantes del conocimiento personal. + +**Alcance** +- Calcular una métrica simple de centralidad usando: + - backlinks + - links salientes + - uso + - co-uso +- Mostrar bloque “Notas centrales” en dashboard + +**Criterios de aceptación** +- El dashboard muestra las notas más centrales +- La métrica está documentada y es reproducible +- Los resultados cambian al aumentar conexiones reales + +**Archivos sugeridos** +- `src/lib/centrality.ts` +- `src/lib/dashboard.ts` +- `src/components/dashboard.tsx` +- `__tests__/centrality.test.ts` + +--- + +# EPIC 5 — Captura aún más rápida + +## [P1] Ticket 12 — Mejorar Quick Add para texto multilinea + +**Objetivo** +Permitir capturar cosas más complejas sin abrir el formulario completo. + +**Alcance** +- Soportar textarea o modo expandido en `quick-add` +- Mantener parseo de prefijos y tags +- Permitir pegar bloques de texto/código/listas + +**Criterios de aceptación** +- Se puede guardar una nota multilinea desde quick add +- El parser mantiene tags y tipo correctamente +- No rompe el flujo actual de una sola línea + +**Archivos sugeridos** +- `src/components/quick-add.tsx` +- `src/lib/quick-add.ts` +- `src/app/api/notes/quick/route.ts` + +--- + +## [P2] Ticket 13 — Pegado inteligente en Quick Add y formulario + +**Objetivo** +Inferir estructura útil cuando el usuario pega contenido. + +**Alcance** +- Detectar si el pegado parece: + - comando + - snippet + - checklist + - markdown +- Preformatear el contenido para mejor guardado +- Ofrecer sugerencia de tipo o plantilla + +**Criterios de aceptación** +- Pegar código sugiere `snippet` +- Pegar checklist preserva formato +- Pegar comando corto no destruye el flujo rápido + +**Archivos sugeridos** +- `src/lib/paste-analysis.ts` +- `src/components/quick-add.tsx` +- `src/components/note-form.tsx` + +--- + +# EPIC 6 — Métricas y observabilidad de producto + +## [P2] Ticket 14 — Crear métricas internas simples + +**Objetivo** +Medir qué partes del producto generan valor real. + +**Alcance** +- Crear panel o función para obtener: + - notas más abiertas + - tipos más usados + - tags más usados + - quick add vs formulario +- Exponer datos al dashboard o settings + +**Criterios de aceptación** +- Las métricas se calculan sin servicios externos +- Se pueden consultar desde UI +- No impactan negativamente el rendimiento percibido + +**Archivos sugeridos** +- `src/lib/metrics.ts` +- `src/app/settings/page.tsx` o `src/app/page.tsx` +- `src/app/api/metrics/route.ts` + +--- + +## [P2] Ticket 15 — Instrumentar origen de creación de nota + +**Objetivo** +Saber qué flujo usan más los usuarios. + +**Alcance** +- Registrar si la nota se creó desde: + - quick add + - formulario completo + - importación +- Guardar origen de creación +- Exponerlo en métricas + +**Criterios de aceptación** +- Cada nota nueva guarda su origen +- Las métricas muestran distribución por origen +- No rompe notas existentes + +**Archivos sugeridos** +- `prisma/schema.prisma` +- `src/app/api/notes/route.ts` +- `src/app/api/notes/quick/route.ts` +- `src/app/api/export-import/route.ts` +- `src/lib/metrics.ts` + +--- + +# EPIC 7 — Calidad y estabilidad + +## [P1] Ticket 16 — Tests unitarios para ranking y recomendaciones MVP-3 + +**Objetivo** +Proteger la lógica nueva antes de seguir iterando. + +**Alcance** +- Tests para: + - `usage.ts` + - `dashboard.ts` + - `recommendations.ts` + - `type-inference.ts` + - `centrality.ts` + +**Criterios de aceptación** +- Casos felices y bordes cubiertos +- Tests reproducibles con datos determinísticos +- Los pesos de scoring quedan documentados por test + +--- + +## [P1] Ticket 17 — Tests de integración para dashboard y usage tracking + +**Objetivo** +Validar flujos reales del MVP-3. + +**Alcance** +- Probar: + - apertura/uso de nota + - dashboard enriquecido + - recomendaciones + - métricas base + +**Criterios de aceptación** +- Existen seeds o fixtures simples +- Los endpoints responden con estructura consistente +- No hay regresiones en APIs existentes + +--- + +## [P2] Ticket 18 — Feature flags internas para MVP-3 + +**Objetivo** +Introducir features progresivamente sin romper experiencia principal. + +**Alcance** +- Crear configuración simple para activar/desactivar: + - centralidad + - recomendaciones pasivas + - sugerencias de tipo + - sugerencias de links +- Leer flags desde config local o env + +**Criterios de aceptación** +- Cada feature nueva puede apagarse sin romper la app +- Los componentes respetan flags en servidor y cliente + +**Archivos sugeridos** +- `src/lib/features.ts` +- `src/app/layout.tsx` +- componentes afectados + +--- + +# Orden recomendado de implementación + +## Sprint 1 +- Ticket 02 — Registrar eventos de uso de notas +- Ticket 04 — Extender scoring con señales de uso real +- Ticket 01 — Rediseñar dashboard para valor inmediato +- Ticket 03 — Mostrar “según tu actividad reciente” +- Ticket 16 — Tests unitarios MVP-3 base + +## Sprint 2 +- Ticket 05 — Recomendaciones pasivas en detalle +- Ticket 07 — Sugerir tags automáticamente +- Ticket 10 — Panel “Conectado con” +- Ticket 17 — Tests de integración + +## Sprint 3 +- Ticket 12 — Quick Add multilinea +- Ticket 13 — Pegado inteligente +- Ticket 08 — Sugerir tipo automáticamente +- Ticket 09 — Sugerir links internos + +## Sprint 4 +- Ticket 06 — Registrar co-uso +- Ticket 11 — Identificar notas centrales +- Ticket 14 — Métricas internas simples +- Ticket 15 — Origen de creación +- Ticket 18 — Feature flags internas + +--- + +# Plantilla sugerida para cada issue en Claude Code + +## Título +`[P1] Registrar eventos de uso de notas` + +## Contexto +Recall ya cuenta con CRUD, búsqueda con scoring, quick add, backlinks, relaciones y tests. Ahora se necesita capturar señales reales de uso para mejorar ranking y recomendaciones. + +## Objetivo +Registrar aperturas y acciones clave sobre notas para alimentar el dashboard, la recuperación pasiva y el ranking inteligente. + +## Alcance +- modelo o estructura de persistencia +- utilidades de registro +- integración mínima con UI y/o endpoints +- tests asociados + +## No incluye +- analytics de terceros +- tracking publicitario +- panel complejo de observabilidad + +## Criterios de aceptación +- ... +- ... +- ... + +## Archivos a tocar +- ... +- ... + +## Notas técnicas +- mantener compatibilidad con Prisma + SQLite +- implementación segura ante fallos +- no romper experiencia actual + +--- + +# Definición de Done + +- Código implementado y tipado +- Tests pasando +- Sin regresiones en CRUD/búsqueda/quick add +- UI usable en estados vacío, normal y borde +- Lógica desacoplada en `lib/` diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bbc27a1..73bde07 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,7 @@ model Note { tags NoteTag[] backlinks Backlink[] @relation("BacklinkTarget") outbound Backlink[] @relation("BacklinkSource") + usageEvents NoteUsage[] } model Tag { @@ -46,3 +47,16 @@ model Backlink { @@unique([sourceNoteId, targetNoteId]) } + +model NoteUsage { + id String @id @default(cuid()) + noteId String + note Note @relation(fields: [noteId], references: [id], onDelete: Cascade) + eventType String // 'view' | 'search_click' | 'related_click' | 'link_click' + query String? + metadata String? + createdAt DateTime @default(now()) + + @@index([noteId, createdAt]) + @@index([eventType, createdAt]) +} diff --git a/src/app/api/usage/route.ts b/src/app/api/usage/route.ts new file mode 100644 index 0000000..99e8631 --- /dev/null +++ b/src/app/api/usage/route.ts @@ -0,0 +1,30 @@ +import { NextRequest } from 'next/server' +import { createErrorResponse, createSuccessResponse } from '@/lib/errors' +import { trackNoteUsage, type UsageEventType } from '@/lib/usage' + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const { noteId, eventType, query, metadata } = body + + if (!noteId || !eventType) { + return createErrorResponse(new Error('noteId and eventType are required')) + } + + const validEventTypes: UsageEventType[] = ['view', 'search_click', 'related_click', 'link_click'] + if (!validEventTypes.includes(eventType)) { + return createErrorResponse(new Error('Invalid eventType')) + } + + await trackNoteUsage({ + noteId, + eventType, + query, + metadata, + }) + + return createSuccessResponse({ success: true }) + } catch (error) { + return createErrorResponse(error) + } +} diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx index 4137274..1e404c7 100644 --- a/src/app/notes/[id]/page.tsx +++ b/src/app/notes/[id]/page.tsx @@ -4,6 +4,7 @@ import { RelatedNotes } from '@/components/related-notes' import { getRelatedNotes } from '@/lib/related' import { MarkdownContent } from '@/components/markdown-content' import { DeleteNoteButton } from '@/components/delete-note-button' +import { TrackNoteView } from '@/components/track-note-view' import Link from 'next/link' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -35,6 +36,8 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id: const noteType = note.type as NoteType return ( + <> +
@@ -82,12 +85,17 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id: )}
- +
{related.length > 0 && ( )}
+ ) } \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 6900476..a9b0719 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,37 +1,19 @@ -import { prisma } from '@/lib/prisma' import { Dashboard } from '@/components/dashboard' -import { NoteType } from '@/types/note' - -async function getNotes() { - const notes = await prisma.note.findMany({ - include: { tags: { include: { tag: true } } }, - orderBy: { updatedAt: 'desc' }, - }) - return notes -} +import { getDashboardData } from '@/lib/dashboard' export default async function HomePage() { - const allNotes = await getNotes() - - const notesWithTags = allNotes.map(note => ({ - ...note, - createdAt: note.createdAt.toISOString(), - updatedAt: note.updatedAt.toISOString(), - type: note.type as NoteType, - tags: note.tags.map(nt => ({ tag: nt.tag })), - })) - - const recentNotes = notesWithTags.slice(0, 6) - const favoriteNotes = notesWithTags.filter(n => n.isFavorite) - const pinnedNotes = notesWithTags.filter(n => n.isPinned) + const data = await getDashboardData(6) return (
) -} \ No newline at end of file +} diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx index 6bba4b1..6d7ba30 100644 --- a/src/components/dashboard.tsx +++ b/src/components/dashboard.tsx @@ -5,13 +5,25 @@ import { Note } from '@/types/note' import { NoteList } from './note-list' import { Button } from '@/components/ui/button' import { SearchBar } from './search-bar' -import { ArrowRight } from 'lucide-react' +import { ArrowRight, TrendingUp, Terminal, Code, Zap } from 'lucide-react' -export function Dashboard({ recentNotes, favoriteNotes, pinnedNotes }: { +interface DashboardProps { recentNotes: Note[] - favoriteNotes: Note[] - pinnedNotes: Note[] -}) { + mostUsedNotes: Note[] + recentCommands: Note[] + recentSnippets: Note[] + activityBasedNotes: Note[] + hasActivity: boolean +} + +export function Dashboard({ + recentNotes, + mostUsedNotes, + recentCommands, + recentSnippets, + activityBasedNotes, + hasActivity, +}: DashboardProps) { return ( <>
@@ -19,27 +31,12 @@ export function Dashboard({ recentNotes, favoriteNotes, pinnedNotes }: {
- {pinnedNotes.length > 0 && ( -
-

- 📌 Pineadas -

- -
- )} - - {favoriteNotes.length > 0 && ( -
-

- ❤️ Favoritas -

- -
- )} - + {/* Recientes */}
-

Recientes

+

+ Recientes +

- -
+ )}
+ + {/* Más usadas */} + {mostUsedNotes.length > 0 && ( +
+
+

+ + Más usadas +

+ + + +
+ +
+ )} + + {/* Comandos recientes */} + {recentCommands.length > 0 && ( +
+
+

+ + Comandos recientes +

+ + + +
+ +
+ )} + + {/* Snippets recientes */} + {recentSnippets.length > 0 && ( +
+
+

+ + Snippets recientes +

+ + + +
+ +
+ )} + + {/* Según tu actividad */} + {hasActivity && activityBasedNotes.length > 0 && ( +
+
+

+ + Según tu actividad +

+
+ +
+ )} +
) -} \ No newline at end of file +} + +function EmptyState() { + return ( +
+

No hay notas todavía.

+ + + +
+ ) +} diff --git a/src/components/markdown-content.tsx b/src/components/markdown-content.tsx index 3ff4b3f..0124efd 100644 --- a/src/components/markdown-content.tsx +++ b/src/components/markdown-content.tsx @@ -8,19 +8,24 @@ import { NoteType } from '@/types/note' import { Copy, Check } from 'lucide-react' import { useState } from 'react' import { cn } from '@/lib/utils' +import { trackNoteUsage } from '@/lib/usage' interface MarkdownContentProps { content: string className?: string noteType?: NoteType + noteId?: string } -function CopyButton({ text }: { text: string }) { +function CopyButton({ text, noteId, eventType }: { text: string; noteId?: string; eventType?: 'copy_command' | 'copy_snippet' }) { const [copied, setCopied] = useState(false) const handleCopy = async () => { await navigator.clipboard.writeText(text) setCopied(true) + if (noteId && eventType) { + trackNoteUsage({ noteId, eventType }) + } setTimeout(() => setCopied(false), 2000) } @@ -88,7 +93,7 @@ function ProcedureCheckboxes({ content }: { content: string }) { ) } -export function MarkdownContent({ content, className = '', noteType }: MarkdownContentProps) { +export function MarkdownContent({ content, className = '', noteType, noteId }: MarkdownContentProps) { if (noteType === 'procedure') { return (
@@ -118,7 +123,7 @@ export function MarkdownContent({ content, className = '', noteType }: MarkdownC > {codeString} - +
) } @@ -134,7 +139,7 @@ export function MarkdownContent({ content, className = '', noteType }: MarkdownC > {codeString} - + ) } @@ -157,7 +162,7 @@ export function MarkdownContent({ content, className = '', noteType }: MarkdownC > {codeString} - + ) }, diff --git a/src/components/track-note-view.tsx b/src/components/track-note-view.tsx new file mode 100644 index 0000000..cc4f739 --- /dev/null +++ b/src/components/track-note-view.tsx @@ -0,0 +1,12 @@ +'use client' + +import { useEffect } from 'react' +import { addToRecentlyViewed } from '@/lib/usage' + +export function TrackNoteView({ noteId }: { noteId: string }) { + useEffect(() => { + addToRecentlyViewed(noteId) + }, [noteId]) + + return null +} diff --git a/src/lib/dashboard.ts b/src/lib/dashboard.ts new file mode 100644 index 0000000..9b71b77 --- /dev/null +++ b/src/lib/dashboard.ts @@ -0,0 +1,103 @@ +import { prisma } from '@/lib/prisma' +import { getRecentlyUsedNotes } from '@/lib/usage' +import { NoteType } from '@/types/note' + +export interface DashboardNote { + id: string + title: string + content: string + type: NoteType + isFavorite: boolean + isPinned: boolean + createdAt: string + updatedAt: string + tags: { tag: { id: string; name: string } }[] + usageCount?: number +} + +export interface DashboardData { + recentNotes: DashboardNote[] + mostUsedNotes: DashboardNote[] + recentCommands: DashboardNote[] + recentSnippets: DashboardNote[] + activityBasedNotes: DashboardNote[] + hasActivity: boolean +} + +export async function getDashboardData(limit = 6): Promise { + // Get all notes with tags + const allNotes = await prisma.note.findMany({ + include: { tags: { include: { tag: true } } }, + orderBy: { updatedAt: 'desc' }, + }) + + const notesWithTags: DashboardNote[] = allNotes.map((note) => ({ + id: note.id, + title: note.title, + content: note.content, + type: note.type as NoteType, + isFavorite: note.isFavorite, + isPinned: note.isPinned, + createdAt: note.createdAt.toISOString(), + updatedAt: note.updatedAt.toISOString(), + tags: note.tags.map((nt) => ({ tag: nt.tag })), + })) + + // Get usage data + const recentlyUsed = await getRecentlyUsedNotes('default', limit * 2, 30) + const usageMap = new Map(recentlyUsed.map((u) => [u.noteId, u.count])) + + // Add usage count to notes + const notesWithUsage = notesWithTags.map((note) => ({ + ...note, + usageCount: usageMap.get(note.id) ?? 0, + })) + + // Recent notes (by updatedAt) + const recentNotes = notesWithTags.slice(0, limit) + + // Most used notes (by usage count, not just updatedAt) + const mostUsedNotes = [...notesWithUsage] + .filter((n) => n.usageCount > 0) + .sort((a, b) => b.usageCount - a.usageCount) + .slice(0, limit) + + // Recent commands (type = command) + const recentCommands = notesWithTags + .filter((n) => n.type === 'command') + .slice(0, limit) + + // Recent snippets (type = snippet) + const recentSnippets = notesWithTags + .filter((n) => n.type === 'snippet') + .slice(0, limit) + + // Activity-based recommendations (recently used, excluding already viewed recently) + const recentNoteIds = new Set(recentNotes.map((n) => n.id)) + const activityBasedNotes = recentlyUsed + .filter((u) => !recentNoteIds.has(u.noteId)) + .slice(0, limit) + .map((u) => notesWithTags.find((n) => n.id === u.noteId)) + .filter((n): n is DashboardNote => n !== undefined) + + const hasActivity = recentlyUsed.length > 0 + + return { + recentNotes, + mostUsedNotes, + recentCommands, + recentSnippets, + activityBasedNotes, + hasActivity, + } +} + +export function hasVisibleBlocks(data: DashboardData): boolean { + return ( + data.recentNotes.length > 0 || + data.mostUsedNotes.length > 0 || + data.recentCommands.length > 0 || + data.recentSnippets.length > 0 || + data.activityBasedNotes.length > 0 + ) +} diff --git a/src/lib/related.ts b/src/lib/related.ts index 9ab2c4d..3323ce6 100644 --- a/src/lib/related.ts +++ b/src/lib/related.ts @@ -1,4 +1,5 @@ import { prisma } from '@/lib/prisma' +import { getUsageStats } from '@/lib/usage' // Stop words to filter out from content matching (English + Spanish) const STOP_WORDS = new Set([ @@ -141,6 +142,16 @@ export async function getRelatedNotes(noteId: string, limit = 5): Promise= 1 || usageStats.relatedClicks >= 1) { + score += 2 + } + // Solo incluir si tiene score > 0 Y al menos una razón válida if (score > 0 && reasons.length > 0) { scored.push({ diff --git a/src/lib/search.ts b/src/lib/search.ts index 255cf8d..ce063a4 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -1,5 +1,6 @@ import { prisma } from '@/lib/prisma' import stringSimilarity from 'string-similarity' +import { getUsageStats } from '@/lib/usage' export interface SearchFilters { type?: string @@ -65,7 +66,7 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function scoreNote( +async function scoreNote( note: { id: string title: string @@ -79,7 +80,7 @@ function scoreNote( }, query: string, exactTitleMatch: boolean -): { score: number; matchType: 'exact' | 'fuzzy' } { +): Promise<{ score: number; matchType: 'exact' | 'fuzzy' }> { let score = 0 let matchType: 'exact' | 'fuzzy' = 'exact' const queryLower = query.toLowerCase() @@ -118,6 +119,16 @@ function scoreNote( score += 1 } + // Usage-based boost (small, does not eclipse text match) + // +1 per 5 views (max +3), +2 if used recently (recency) + const usageStats = await getUsageStats(note.id, 7) // last 7 days for recency + const viewBoost = Math.min(Math.floor(usageStats.views / 5), 3) + score += viewBoost + // Recency: if used in last 2 days, add +2 + if (usageStats.views >= 1 || usageStats.clicks >= 1) { + score += 2 + } + return { score, matchType } } @@ -166,7 +177,7 @@ export async function noteQuery( } } - const { score, matchType } = scoreNote(note, queryLower, exactTitleMatch || exactContentMatch) + const { score, matchType } = await scoreNote(note, queryLower, exactTitleMatch || exactContentMatch) const highlight = highlightMatches( exactTitleMatch ? note.title + ' ' + note.content : note.content, diff --git a/src/lib/usage.ts b/src/lib/usage.ts new file mode 100644 index 0000000..c1cb073 --- /dev/null +++ b/src/lib/usage.ts @@ -0,0 +1,138 @@ +import { prisma } from '@/lib/prisma' + +export type UsageEventType = 'view' | 'search_click' | 'related_click' | 'link_click' | 'copy_command' | 'copy_snippet' + +interface UsageEvent { + noteId: string + eventType: UsageEventType + query?: string + metadata?: Record +} + +export async function trackNoteUsage(event: UsageEvent): Promise { + try { + await prisma.noteUsage.create({ + data: { + noteId: event.noteId, + eventType: event.eventType, + query: event.query ?? null, + metadata: event.metadata ? JSON.stringify(event.metadata) : null, + }, + }) + } catch { + // Silently fail - do not break UI + } +} + +export async function getNoteUsageCount( + noteId: string, + eventType?: UsageEventType, + days = 30 +): Promise { + try { + const where: { noteId: string; createdAt: { gte: Date }; eventType?: string } = { + noteId, + createdAt: { + gte: new Date(Date.now() - days * 24 * 60 * 60 * 1000), + }, + } + if (eventType) { + where.eventType = eventType + } + return await prisma.noteUsage.count({ where }) + } catch { + return 0 + } +} + +export async function getRecentlyUsedNotes( + userId: string, + limit = 10, + days = 30 +): Promise<{ noteId: string; count: number; lastUsed: Date }[]> { + try { + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + const results = await prisma.noteUsage.groupBy({ + by: ['noteId'], + where: { + createdAt: { gte: since }, + }, + _count: { id: true }, + orderBy: { _count: { id: 'desc' } }, + take: limit, + }) + + const noteIds = results.map((r) => r.noteId) + const notes = await prisma.note.findMany({ + where: { id: { in: noteIds } }, + select: { id: true, updatedAt: true }, + }) + const noteMap = new Map(notes.map((n) => [n.id, n])) + + return results.map((r) => ({ + noteId: r.noteId, + count: r._count.id, + lastUsed: noteMap.get(r.noteId)?.updatedAt ?? new Date(), + })) + } catch { + return [] + } +} + +export async function getUsageStats( + noteId: string, + days = 30 +): Promise<{ views: number; clicks: number; relatedClicks: number }> { + try { + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + const [views, clicks, relatedClicks] = await Promise.all([ + prisma.noteUsage.count({ + where: { noteId, eventType: 'view', createdAt: { gte: since } }, + }), + prisma.noteUsage.count({ + where: { noteId, eventType: 'search_click', createdAt: { gte: since } }, + }), + prisma.noteUsage.count({ + where: { noteId, eventType: 'related_click', createdAt: { gte: since } }, + }), + ]) + return { views, clicks, relatedClicks } + } catch { + return { views: 0, clicks: 0, relatedClicks: 0 } + } +} + +// localStorage recentlyViewed (last 10 notes) +const RECENTLY_VIEWED_KEY = 'recall_recently_viewed' +const MAX_RECENTLY_VIEWED = 10 + +export function getRecentlyViewedIds(): string[] { + if (typeof window === 'undefined') return [] + try { + const stored = localStorage.getItem(RECENTLY_VIEWED_KEY) + return stored ? JSON.parse(stored) : [] + } catch { + return [] + } +} + +export function addToRecentlyViewed(noteId: string): void { + if (typeof window === 'undefined') return + try { + const recent = getRecentlyViewedIds() + const filtered = recent.filter(id => id !== noteId) + const updated = [noteId, ...filtered].slice(0, MAX_RECENTLY_VIEWED) + localStorage.setItem(RECENTLY_VIEWED_KEY, JSON.stringify(updated)) + } catch { + // Silently fail + } +} + +export function clearRecentlyViewed(): void { + if (typeof window === 'undefined') return + try { + localStorage.removeItem(RECENTLY_VIEWED_KEY) + } catch { + // Silently fail + } +}