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.')
})
})
})

View File

@@ -0,0 +1,221 @@
import { inferNoteType, formatContentForType } from '@/lib/type-inference'
describe('type-inference.ts', () => {
describe('inferNoteType', () => {
describe('command detection', () => {
it('detects git commands', () => {
const content = 'git commit -m "fix: resolve issue"\nnpm install\ndocker build'
const result = inferNoteType(content)
expect(result?.type).toBe('command')
expect(result?.confidence).toBeTruthy() // any confidence level
})
it('detects docker commands', () => {
const content = 'docker build -t myapp .\ndocker run -d'
const result = inferNoteType(content)
expect(result?.type).toBe('command')
})
it('detects shell prompts', () => {
const content = '$ curl -X POST https://api.example.com\n$ npm install'
const result = inferNoteType(content)
expect(result?.type).toBe('command')
})
it('detects shebang', () => {
const content = '#!/bin/bash\necho "Hello"'
const result = inferNoteType(content)
expect(result?.type).toBe('command')
})
})
describe('snippet detection', () => {
it('detects code blocks', () => {
const content = '```javascript\nconst x = 1;\n```'
const result = inferNoteType(content)
expect(result?.type).toBe('snippet')
})
it('detects function declarations', () => {
const content = 'function hello() {\n return "world";\n}'
const result = inferNoteType(content)
expect(result?.type).toBe('snippet')
})
it('detects ES6 imports', () => {
const content = 'import React from "react"\nexport default App'
const result = inferNoteType(content)
expect(result?.type).toBe('snippet')
})
it('detects arrow functions', () => {
const content = 'const add = (a, b) => a + b;'
const result = inferNoteType(content)
expect(result?.type).toBe('snippet')
})
it('detects object literals', () => {
const content = 'const config = {\n name: "app",\n version: "1.0.0"\n}'
const result = inferNoteType(content)
expect(result?.type).toBe('snippet')
})
})
describe('procedure detection', () => {
it('detects numbered steps', () => {
const content = '1. First step\n2. Second step\n3. Third step'
const result = inferNoteType(content)
expect(result?.type).toBe('procedure')
})
it('detects bullet points as steps', () => {
const content = '- Open the terminal\n- Run the command\n- Check the output'
const result = inferNoteType(content)
expect(result?.type).toBe('procedure')
})
it('detects step-related keywords', () => {
const content = 'primer paso, segundo paso, tercer paso, finally'
const result = inferNoteType(content)
expect(result?.type).toBe('procedure')
})
it('detects tutorial language', () => {
const content = 'How to install Node.js:\n1. Download the installer\n2. Run the setup'
const result = inferNoteType(content)
expect(result?.type).toBe('procedure')
})
})
describe('recipe detection', () => {
it('detects ingredients pattern', () => {
const content = 'ingredientes:\n- 2 tazas de harina\n- 1 taza de azúcar'
const result = inferNoteType(content)
expect(result?.type).toBe('recipe')
})
it('detects recipe-related keywords', () => {
const content = 'receta:\n1. sofreír cebolla\n2. añadir arroz\ntiempo: 30 minutos'
const result = inferNoteType(content)
expect(result?.type).toBe('recipe')
})
})
describe('decision detection', () => {
it('detects decision context', () => {
const content = 'Decisión: Usar PostgreSQL en lugar de MySQL\n\nRazón: Mejor soporte para JSON y transacciones.'
const result = inferNoteType(content)
expect(result?.type).toBe('decision')
})
it('detects pros and cons', () => {
const content = 'decisión:\nPros: Better performance\nContras: More expensive'
const result = inferNoteType(content)
expect(result?.type).toBe('decision')
})
it('detects alternatives considered', () => {
const content = 'Alternativas consideradas:\n1. AWS\n2. GCP\n3. Azure\n\nElegimos Vercel por su integración con Next.js.'
const result = inferNoteType(content)
expect(result?.type).toBe('decision')
})
})
describe('inventory detection', () => {
it('detects quantity patterns', () => {
const content = 'Item: Laptop\nCantidad: 5\nUbicación: Oficina principal'
const result = inferNoteType(content)
expect(result?.type).toBe('inventory')
})
it('detects inventory-related keywords', () => {
const content = 'Stock disponible: 100 unidades\nNivel mínimo: 20'
const result = inferNoteType(content)
expect(result?.type).toBe('inventory')
})
})
describe('edge cases', () => {
it('returns note type for generic content', () => {
const content = 'This is a simple note about my day.'
const result = inferNoteType(content)
expect(result?.type).toBe('note')
})
it('returns note type with low confidence for empty content', () => {
const result = inferNoteType('')
expect(result?.type).toBe('note')
expect(result?.confidence).toBe('low')
})
it('returns note type with low confidence for very short content', () => {
const result = inferNoteType('Hi')
expect(result?.type).toBe('note')
expect(result?.confidence).toBe('low')
})
it('prioritizes highest confidence match', () => {
// Command-like code with shell prompt - medium confidence (2 patterns)
const content = '$ npm install\ngit commit -m "fix"'
const result = inferNoteType(content)
expect(result?.type).toBe('command')
})
})
})
describe('formatContentForType', () => {
it('formats command type', () => {
const content = 'git status'
const result = formatContentForType(content, 'command')
expect(result).toContain('## Comando')
expect(result).toContain('## Cuándo usarlo')
expect(result).toContain('## Ejemplo')
})
it('formats snippet type', () => {
const content = 'const x = 1;'
const result = formatContentForType(content, 'snippet')
expect(result).toContain('## Snippet')
expect(result).toContain('## Lenguaje')
expect(result).toContain('## Código')
})
it('formats procedure type', () => {
const content = '1. Step one\n2. Step two'
const result = formatContentForType(content, 'procedure')
expect(result).toContain('## Objetivo')
expect(result).toContain('## Pasos')
expect(result).toContain('## Requisitos')
})
it('formats recipe type', () => {
const content = 'Ingredients:\n- Flour\n- Sugar'
const result = formatContentForType(content, 'recipe')
expect(result).toContain('## Ingredientes')
expect(result).toContain('## Pasos')
expect(result).toContain('## Tiempo')
})
it('formats decision type', () => {
const content = 'Use TypeScript'
const result = formatContentForType(content, 'decision')
expect(result).toContain('## Contexto')
expect(result).toContain('## Decisión')
expect(result).toContain('## Alternativas')
})
it('formats inventory type', () => {
const content = 'Laptop model X'
const result = formatContentForType(content, 'inventory')
expect(result).toContain('## Item')
expect(result).toContain('## Cantidad')
expect(result).toContain('## Ubicación')
})
it('formats note type', () => {
const content = 'Simple note content'
const result = formatContentForType(content, 'note')
expect(result).toContain('## Notas')
})
})
})