feat: MVP-3 Sprint 4 - Co-usage, metrics, centrality, creation source, feature flags

- Add NoteCoUsage model and co-usage tracking when viewing notes
- Add creationSource field to notes (form/quick/import)
- Add dashboard metrics API (/api/metrics)
- Add centrality calculation (/api/centrality)
- Add feature flags system for toggling features
- Add multiline QuickAdd with smart paste type detection
- Add internal link suggestions while editing notes
- Add type inference for automatic note type detection
- Add comprehensive tests for type-inference and link-suggestions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 16:50:40 -03:00
parent ef0aebf510
commit ff7223bfea
20 changed files with 1388 additions and 54 deletions

View File

@@ -0,0 +1,171 @@
import { findLinkSuggestions, applyWikiLinks } from '@/lib/link-suggestions'
// Mock prisma
jest.mock('@/lib/prisma', () => ({
prisma: {
note: {
findMany: jest.fn(),
},
},
}))
import { prisma } from '@/lib/prisma'
describe('link-suggestions.ts', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('findLinkSuggestions', () => {
it('returns empty array for short content', async () => {
const result = await findLinkSuggestions('Hi')
expect(result).toEqual([])
})
it('returns empty array for empty content', async () => {
const result = await findLinkSuggestions('')
expect(result).toEqual([])
})
it('finds matching note titles in content', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Docker Commands' },
{ id: '2', title: 'Git Tutorial' },
])
const content = 'I use Docker Commands for containers and Git Tutorial for version control.'
const result = await findLinkSuggestions(content)
expect(result).toHaveLength(2)
expect(result.map(r => r.noteTitle)).toContain('Docker Commands')
expect(result.map(r => r.noteTitle)).toContain('Git Tutorial')
})
it('excludes current note from suggestions', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Current Note' },
{ id: '2', title: 'Related Note' },
])
const content = 'See Related Note for details.'
const result = await findLinkSuggestions(content, '1')
expect(result).toHaveLength(1)
expect(result[0].noteTitle).toBe('Related Note')
})
it('sorts by title length (longer first)', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Short' },
{ id: '2', title: 'Very Long Title' },
{ id: '3', title: 'Medium Title' },
])
const content = 'Short and Medium Title and Very Long Title'
const result = await findLinkSuggestions(content)
expect(result[0].noteTitle).toBe('Very Long Title')
expect(result[1].noteTitle).toBe('Medium Title')
expect(result[2].noteTitle).toBe('Short')
})
it('returns empty when no matches found', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Docker' },
{ id: '2', title: 'Git' },
])
const content = 'Python and JavaScript are programming languages.'
const result = await findLinkSuggestions(content)
expect(result).toEqual([])
})
it('handles case-insensitive matching', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Docker Commands' },
])
const content = 'I use DOCKER COMMANDS for my project.'
const result = await findLinkSuggestions(content)
expect(result).toHaveLength(1)
expect(result[0].noteTitle).toBe('Docker Commands')
})
it('matches whole words only', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
{ id: '1', title: 'Git' },
])
const content = 'GitHub uses Git internally.'
const result = await findLinkSuggestions(content)
// Should match standalone 'Git' but not 'Git' within 'GitHub'
// Note: the regex \bGit\b matches standalone 'Git', not 'Git' in 'GitHub'
expect(result.some(r => r.noteTitle === 'Git')).toBe(true)
})
it('returns empty when no notes exist', async () => {
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
const result = await findLinkSuggestions('Some content with potential matches')
expect(result).toEqual([])
})
})
describe('applyWikiLinks', () => {
it('replaces terms with wiki-links', () => {
const content = 'I use Docker and Git for projects.'
const replacements = [
{ term: 'Docker', noteId: '1' },
{ term: 'Git', noteId: '2' },
]
const result = applyWikiLinks(content, replacements)
expect(result).toBe('I use [[Docker]] and [[Git]] for projects.')
})
it('handles multiple occurrences', () => {
const content = 'Docker is great. Docker is fast.'
const replacements = [{ term: 'Docker', noteId: '1' }]
const result = applyWikiLinks(content, replacements)
expect(result).toBe('[[Docker]] is great. [[Docker]] is fast.')
})
it('handles case-insensitive matching and replaces with link term', () => {
const content = 'DOCKER and docker and Docker'
const replacements = [{ term: 'Docker', noteId: '1' }]
const result = applyWikiLinks(content, replacements)
// All variations matched and replaced with the link text
expect(result).toBe('[[Docker]] and [[Docker]] and [[Docker]]')
})
it('returns original content when no replacements', () => {
const content = 'Original content'
const replacements: { term: string; noteId: string }[] = []
const result = applyWikiLinks(content, replacements)
expect(result).toBe('Original content')
})
it('replaces multiple different terms', () => {
const content = 'Use React and TypeScript together.'
const replacements = [
{ term: 'React', noteId: '1' },
{ term: 'TypeScript', noteId: '2' },
]
const result = applyWikiLinks(content, replacements)
expect(result).toBe('Use [[React]] and [[TypeScript]] together.')
})
})
})