develop #1
293
__tests__/dashboard.test.ts
Normal file
293
__tests__/dashboard.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
246
__tests__/related.test.ts
Normal file
246
__tests__/related.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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('<mark>JavaScript</mark>')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
223
__tests__/usage.test.ts
Normal file
223
__tests__/usage.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
581
backlog/recall-mvp3-tickets-claude-code.md
Normal file
581
backlog/recall-mvp3-tickets-claude-code.md
Normal file
@@ -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/`
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
30
src/app/api/usage/route.ts
Normal file
30
src/app/api/usage/route.ts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<TrackNoteView noteId={note.id} />
|
||||
<main className="container mx-auto py-8 px-4 max-w-4xl">
|
||||
<div className="mb-6">
|
||||
<Link href="/notes">
|
||||
@@ -82,12 +85,17 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id:
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
<MarkdownContent content={note.content} className="bg-gray-50 p-4 rounded-lg border" />
|
||||
<MarkdownContent
|
||||
content={note.content}
|
||||
noteType={noteType}
|
||||
className="bg-gray-50 p-4 rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{related.length > 0 && (
|
||||
<RelatedNotes notes={related} />
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,18 @@
|
||||
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 (
|
||||
<main className="container mx-auto pt-8 px-4">
|
||||
<Dashboard
|
||||
recentNotes={recentNotes}
|
||||
favoriteNotes={favoriteNotes}
|
||||
pinnedNotes={pinnedNotes}
|
||||
recentNotes={data.recentNotes}
|
||||
mostUsedNotes={data.mostUsedNotes}
|
||||
recentCommands={data.recentCommands}
|
||||
recentSnippets={data.recentSnippets}
|
||||
activityBasedNotes={data.activityBasedNotes}
|
||||
hasActivity={data.hasActivity}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="flex justify-end mb-3">
|
||||
@@ -19,27 +31,12 @@ export function Dashboard({ recentNotes, favoriteNotes, pinnedNotes }: {
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
|
||||
{pinnedNotes.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-3 flex items-center gap-2">
|
||||
📌 Pineadas
|
||||
</h2>
|
||||
<NoteList notes={pinnedNotes} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{favoriteNotes.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-3 flex items-center gap-2">
|
||||
❤️ Favoritas
|
||||
</h2>
|
||||
<NoteList notes={favoriteNotes} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recientes */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-semibold">Recientes</h2>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span>Recientes</span>
|
||||
</h2>
|
||||
<Link href="/notes">
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
Ver todas <ArrowRight className="h-4 w-4" />
|
||||
@@ -49,15 +46,89 @@ export function Dashboard({ recentNotes, favoriteNotes, pinnedNotes }: {
|
||||
{recentNotes.length > 0 ? (
|
||||
<NoteList notes={recentNotes} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No hay notas todavía.</p>
|
||||
<Link href="/new">
|
||||
<Button className="mt-4">Crea tu primera nota</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<EmptyState />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Más usadas */}
|
||||
{mostUsedNotes.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-orange-500" />
|
||||
<span>Más usadas</span>
|
||||
</h2>
|
||||
<Link href="/notes">
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
Ver todas <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<NoteList notes={mostUsedNotes} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Comandos recientes */}
|
||||
{recentCommands.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-green-500" />
|
||||
<span>Comandos recientes</span>
|
||||
</h2>
|
||||
<Link href="/notes?type=command">
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
Ver todas <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<NoteList notes={recentCommands} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Snippets recientes */}
|
||||
{recentSnippets.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Code className="h-5 w-5 text-blue-500" />
|
||||
<span>Snippets recientes</span>
|
||||
</h2>
|
||||
<Link href="/notes?type=snippet">
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
Ver todas <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<NoteList notes={recentSnippets} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Según tu actividad */}
|
||||
{hasActivity && activityBasedNotes.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-purple-500" />
|
||||
<span>Según tu actividad</span>
|
||||
</h2>
|
||||
</div>
|
||||
<NoteList notes={activityBasedNotes} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No hay notas todavía.</p>
|
||||
<Link href="/new">
|
||||
<Button className="mt-4">Crea tu primera nota</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={cn("prose max-w-none", className)}>
|
||||
@@ -118,7 +123,7 @@ export function MarkdownContent({ content, className = '', noteType }: MarkdownC
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
<CopyButton text={codeString} />
|
||||
<CopyButton text={codeString} noteId={noteId} eventType="copy_snippet" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -134,7 +139,7 @@ export function MarkdownContent({ content, className = '', noteType }: MarkdownC
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
<CopyButton text={codeString} />
|
||||
<CopyButton text={codeString} noteId={noteId} eventType="copy_command" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -157,7 +162,7 @@ export function MarkdownContent({ content, className = '', noteType }: MarkdownC
|
||||
>
|
||||
{codeString}
|
||||
</SyntaxHighlighter>
|
||||
<CopyButton text={codeString} />
|
||||
<CopyButton text={codeString} noteId={noteId} eventType="copy_snippet" />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
12
src/components/track-note-view.tsx
Normal file
12
src/components/track-note-view.tsx
Normal file
@@ -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
|
||||
}
|
||||
103
src/lib/dashboard.ts
Normal file
103
src/lib/dashboard.ts
Normal file
@@ -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<DashboardData> {
|
||||
// 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
|
||||
)
|
||||
}
|
||||
@@ -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<Scored
|
||||
reasons.push(`Content: ${sharedContentWords.slice(0, 2).join(', ')}`)
|
||||
}
|
||||
|
||||
// Usage-based boost (small, does not eclipse content matching)
|
||||
// +1 per 5 views (max +3), +2 if used recently (recency)
|
||||
const usageStats = await getUsageStats(other.id, 7) // last 7 days for recency
|
||||
const viewBoost = Math.min(Math.floor(usageStats.views / 5), 3)
|
||||
score += viewBoost
|
||||
// Recency: if used in last 7 days, add +2
|
||||
if (usageStats.views >= 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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
138
src/lib/usage.ts
Normal file
138
src/lib/usage.ts
Normal file
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
export async function trackNoteUsage(event: UsageEvent): Promise<void> {
|
||||
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<number> {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user