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

89
src/lib/centrality.ts Normal file
View File

@@ -0,0 +1,89 @@
import { prisma } from '@/lib/prisma'
export interface CentralNote {
id: string
title: string
type: string
centralityScore: number
backlinks: number
outboundLinks: number
usageViews: number
coUsageWeight: number
}
/**
* Calculate centrality score for all notes.
* A note is "central" if it has many connections (backlinks/outbound) and high usage.
*/
export async function getCentralNotes(limit = 10): Promise<CentralNote[]> {
try {
// Get all notes with their counts
const notes = await prisma.note.findMany({
select: {
id: true,
title: true,
type: true,
backlinks: { select: { id: true } },
outbound: { select: { id: true } },
},
})
// Get usage stats for all notes
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
const usageStats = await prisma.noteUsage.groupBy({
by: ['noteId'],
where: {
eventType: 'view',
createdAt: { gte: thirtyDaysAgo },
},
_count: { id: true },
})
const usageMap = new Map(usageStats.map((u) => [u.noteId, u._count.id]))
// Get co-usage stats
const coUsageStats = await prisma.noteCoUsage.groupBy({
by: ['fromNoteId', 'toNoteId'],
_sum: { weight: true },
})
const coUsageMap = new Map<string, number>()
for (const cu of coUsageStats) {
coUsageMap.set(cu.fromNoteId, (coUsageMap.get(cu.fromNoteId) || 0) + (cu._sum.weight || 0))
coUsageMap.set(cu.toNoteId, (coUsageMap.get(cu.toNoteId) || 0) + (cu._sum.weight || 0))
}
// Calculate centrality score for each note
const scoredNotes: CentralNote[] = notes.map((note) => {
const backlinks = note.backlinks.length
const outboundLinks = note.outbound.length
const usageViews = usageMap.get(note.id) || 0
const coUsageWeight = coUsageMap.get(note.id) || 0
// Centrality formula:
// - Each backlink = 3 points (incoming connections show importance)
// - Each outbound link = 1 point (shows knowledge breadth)
// - Each usage view = 0.5 points (shows relevance)
// - Each co-usage weight = 2 points (shows related usage patterns)
const centralityScore =
backlinks * 3 + outboundLinks * 1 + usageViews * 0.5 + coUsageWeight * 2
return {
id: note.id,
title: note.title,
type: note.type,
centralityScore,
backlinks,
outboundLinks,
usageViews,
coUsageWeight,
}
})
// Sort by centrality score descending
scoredNotes.sort((a, b) => b.centralityScore - a.centralityScore)
return scoredNotes.slice(0, limit)
} catch (error) {
console.error('Error calculating centrality:', error)
return []
}
}

56
src/lib/features.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* Feature flags for MVP-3 features.
* Can be toggled via environment variables or local config.
*/
export interface FeatureFlags {
centrality: boolean
passiveRecommendations: boolean
typeSuggestions: boolean
linkSuggestions: boolean
}
// Default values - all enabled unless explicitly disabled
const defaults: FeatureFlags = {
centrality: true,
passiveRecommendations: true,
typeSuggestions: true,
linkSuggestions: true,
}
// Environment variable parsing
function parseEnvBool(key: string, defaultValue: boolean): boolean {
const envValue = process.env[key]
if (envValue === undefined) return defaultValue
if (envValue === 'true' || envValue === '1') return true
if (envValue === 'false' || envValue === '0') return false
return defaultValue
}
/**
* Get current feature flags.
* Reads from environment variables with defaults.
*/
export function getFeatureFlags(): FeatureFlags {
return {
centrality: parseEnvBool('FLAG_CENTRALITY', defaults.centrality),
passiveRecommendations: parseEnvBool('FLAG_PASSIVE_RECOMMENDATIONS', defaults.passiveRecommendations),
typeSuggestions: parseEnvBool('FLAG_TYPE_SUGGESTIONS', defaults.typeSuggestions),
linkSuggestions: parseEnvBool('FLAG_LINK_SUGGESTIONS', defaults.linkSuggestions),
}
}
/**
* Check if a specific feature is enabled.
*/
export function isFeatureEnabled(feature: keyof FeatureFlags): boolean {
return getFeatureFlags()[feature]
}
/**
* Get flags for client-side use (only boolean values).
* This is safe to expose to the client.
*/
export function getClientFeatureFlags(): FeatureFlags {
return getFeatureFlags()
}

View File

@@ -0,0 +1,72 @@
import { prisma } from '@/lib/prisma'
export interface LinkSuggestion {
term: string
noteId: string
noteTitle: string
}
/**
* Find potential wiki-link suggestions in content.
* Returns notes whose titles appear in the content and could be converted to [[links]].
*/
export async function findLinkSuggestions(
content: string,
currentNoteId?: string
): Promise<LinkSuggestion[]> {
if (!content.trim() || content.length < 10) {
return []
}
// Get all note titles except current note
const allNotes = await prisma.note.findMany({
where: currentNoteId ? { id: { not: currentNoteId } } : undefined,
select: { id: true, title: true },
})
if (allNotes.length === 0) {
return []
}
// Find titles that appear in content (case-insensitive)
const suggestions: LinkSuggestion[] = []
const contentLower = content.toLowerCase()
for (const note of allNotes) {
const titleLower = note.title.toLowerCase()
// Check if title appears as a whole word in content
const regex = new RegExp(`\\b${escapeRegex(titleLower)}\\b`, 'i')
if (regex.test(content)) {
suggestions.push({
term: note.title,
noteId: note.id,
noteTitle: note.title,
})
}
}
// Sort by title length (longer titles first - more specific matches)
return suggestions.sort((a, b) => b.noteTitle.length - a.noteTitle.length)
}
/**
* Replace terms in content with wiki-links
*/
export function applyWikiLinks(
content: string,
replacements: { term: string; noteId: string }[]
): string {
let result = content
for (const { term, noteId } of replacements) {
// Replace all occurrences of the term (case-insensitive, whole word only)
const regex = new RegExp(`\\b(${escapeRegex(term)})\\b`, 'gi')
result = result.replace(regex, `[[${term}]]`)
}
return result
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

110
src/lib/metrics.ts Normal file
View File

@@ -0,0 +1,110 @@
import { prisma } from '@/lib/prisma'
export interface DashboardMetrics {
totalNotes: number
totalTags: number
notesByType: Record<string, number>
topTags: { name: string; count: number }[]
topViewedNotes: { id: string; title: string; type: string; views: number }[]
creationSourceStats: { form: number; quick: number; import: number }
}
export async function getDashboardMetrics(days = 30): Promise<DashboardMetrics> {
try {
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
// Get totals
const [totalNotes, totalTags, notesByTypeResult, topTagsResult] = await Promise.all([
prisma.note.count(),
prisma.tag.count(),
prisma.note.groupBy({
by: ['type'],
_count: { id: true },
}),
prisma.noteTag.groupBy({
by: ['tagId'],
_count: { noteId: true },
orderBy: { _count: { noteId: 'desc' } },
take: 10,
}),
])
// Get top viewed notes from usage
const topUsage = await prisma.noteUsage.groupBy({
by: ['noteId'],
where: {
eventType: 'view',
createdAt: { gte: since },
},
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: 10,
})
const topViewedNotes = await Promise.all(
topUsage.map(async (usage) => {
const note = await prisma.note.findUnique({
where: { id: usage.noteId },
select: { id: true, title: true, type: true },
})
return note
? {
id: note.id,
title: note.title,
type: note.type,
views: usage._count.id,
}
: null
})
)
// Get creation source stats
const creationSourceStats = await prisma.note.groupBy({
by: ['creationSource'],
_count: { id: true },
})
const sourceMap = { form: 0, quick: 0, import: 0 }
for (const stat of creationSourceStats) {
if (stat.creationSource in sourceMap) {
sourceMap[stat.creationSource as keyof typeof sourceMap] = stat._count.id
}
}
// Get tag names
const tagIds = topTagsResult.map((t) => t.tagId)
const tags = await prisma.tag.findMany({
where: { id: { in: tagIds } },
select: { id: true, name: true },
})
const tagMap = new Map(tags.map((t) => [t.id, t.name]))
const topTags = topTagsResult
.map((t) => ({
name: tagMap.get(t.tagId) || 'unknown',
count: t._count.noteId,
}))
.filter((t) => t.name !== 'unknown')
return {
totalNotes,
totalTags,
notesByType: Object.fromEntries(
notesByTypeResult.map((r) => [r.type, r._count.id])
),
topTags,
topViewedNotes: topViewedNotes.filter((n): n is NonNullable<typeof n> => n !== null),
creationSourceStats: sourceMap,
}
} catch (error) {
console.error('Error getting dashboard metrics:', error)
return {
totalNotes: 0,
totalTags: 0,
notesByType: {},
topTags: [],
topViewedNotes: [],
creationSourceStats: { form: 0, quick: 0, import: 0 },
}
}
}

143
src/lib/type-inference.ts Normal file
View File

@@ -0,0 +1,143 @@
import { NoteType } from '@/types/note'
interface TypeSuggestion {
type: NoteType
confidence: 'high' | 'medium' | 'low'
reason: string
}
// Patterns that indicate specific note types
const PATTERNS = {
command: [
/^\s*(git|docker|npm|yarn|node|python|curl|wget|ssh|scp|rsync|kubectl|helm|aws|gcloud|az)\s+/m,
/^\$\s*\w+/m,
/^>\s*\w+/m,
/`{3}(bash|sh|shell|zsh|fish)/m,
/#!/m, // shebang
],
snippet: [
/`{3}\w*/m, // code block with language
/^(function|const|let|var|class|import|export|def|async|await)\s+/m,
/\{\s*[\w\s]*:\s*[\w\s,}]+\}/m, // object literal
/=\s*>\s*{/m, // arrow function
/if\s*\(.+\)\s*{/m, // if statement
],
procedure: [
/^\d+[\.\)]\s+\w+/m, // numbered steps: 1. Do this
/^[-*]\s+\w+/m, // bullet steps: - Do this
/primer[oay]|segundo|tercero|cuarto|finalmente|después|antes|mientras|m paso/m,
/pasos?|steps?|instructions?|how to|tutorial/i,
],
recipe: [
/ingredientes?:?\s*$/im,
/^\s*-\s*\d+\s+\w+/m, // ingredient list: - 2 cups flour
/tiempo|horas?|minutos|preparación|cocción|servir/i,
/receta|recetas|cocina|cocinar|hornear|hervir/i,
],
decision: [
/decisión?:?\s*/im,
/alternativas?:?\s*/im,
/pros?\s*y\s*contras?:?\s*/im,
/consideramos?|optamos?|decidimos?|elegimos?/i,
/porque?|reason|context|vista|motivo/i,
],
inventory: [
/cantidad?:?\s*\d+/im,
/ubicación?:?\s*/im,
/stock|inventario|existencia|dispoble/i,
/^\s*\w+\s+\d+\s+\w+/m, // item quantity location pattern
],
}
export function inferNoteType(content: string): TypeSuggestion | null {
const scores: Record<NoteType, { score: number; matchedPatterns: string[] }> = {
command: { score: 0, matchedPatterns: [] },
snippet: { score: 0, matchedPatterns: [] },
procedure: { score: 0, matchedPatterns: [] },
recipe: { score: 0, matchedPatterns: [] },
decision: { score: 0, matchedPatterns: [] },
inventory: { score: 0, matchedPatterns: [] },
note: { score: 0, matchedPatterns: [] },
}
// Check each type's patterns
for (const [type, patterns] of Object.entries(PATTERNS)) {
for (const pattern of patterns) {
if (pattern.test(content)) {
scores[type as NoteType].score += 1
scores[type as NoteType].matchedPatterns.push(pattern.source)
}
}
}
// Find the type with highest score
let bestType: NoteType = 'note'
let bestScore = 0
for (const [type, { score }] of Object.entries(scores)) {
if (score > bestScore) {
bestScore = score
bestType = type as NoteType
}
}
// Determine confidence based on score
let confidence: 'high' | 'medium' | 'low' = 'low'
let reason = 'No clear pattern detected'
if (bestScore >= 3) {
confidence = 'high'
} else if (bestScore >= 2) {
confidence = 'medium'
} else if (bestScore >= 1) {
confidence = 'low'
}
if (bestScore > 0) {
switch (bestType) {
case 'command':
reason = 'Shell command patterns detected'
break
case 'snippet':
reason = 'Code block or programming syntax detected'
break
case 'procedure':
reason = 'Step-by-step instructions detected'
break
case 'recipe':
reason = 'Recipe or cooking instructions detected'
break
case 'decision':
reason = 'Decision-making context detected'
break
case 'inventory':
reason = 'Inventory or quantity patterns detected'
break
}
}
return {
type: bestType,
confidence,
reason,
}
}
export function formatContentForType(content: string, type: NoteType): string {
switch (type) {
case 'command':
return `## Comando\n\n${content.trim()}\n\n## Qué hace\n\n[Descripción]\n\n## Cuándo usarlo\n\n[Cuándo usar este comando]\n\n## Ejemplo\n\n\`\`\`bash\n[ejemplo]\n\`\`\``
case 'snippet':
return `## Snippet\n\n## Lenguaje\n\n[ Lenguaje ]\n\n## Código\n\n\`\`\`\n${content.trim()}\n\`\`\`\n\n## Qué resuelve\n\n[Descripción del problema que resuelve]\n\n## Notas\n\n[Notas adicionales]`
case 'procedure':
return `## Objetivo\n\n[Cuál es el objetivo]\n\n## Pasos\n\n${content.trim()}\n\n## Requisitos\n\n[Requisitos necesarios]\n\n## Problemas comunes\n\n[Problemas frecuentes y soluciones]`
case 'recipe':
return `## Ingredientes\n\n[Lista de ingredientes]\n\n## Pasos\n\n${content.trim()}\n\n## Tiempo\n\n[Tiempo de preparación]\n\n## Notas\n\n[Notas adicionales]`
case 'decision':
return `## Contexto\n\n[Cuál era la situación]\n\n## Decisión\n\n${content.trim()}\n\n## Alternativas consideradas\n\n[Otras opciones evaluadas]\n\n## Consecuencias\n\n[Impacto de esta decisión]`
case 'inventory':
return `## Item\n\n[Nombre del item]\n\n## Cantidad\n\n[Cantidad]\n\n## Ubicación\n\n[Ubicación]\n\n## Notas\n\n[Notas adicionales]`
default:
return `## Notas\n\n${content.trim()}`
}
}

View File

@@ -136,3 +136,67 @@ export function clearRecentlyViewed(): void {
// Silently fail
}
}
// Co-usage tracking: record that two notes were viewed together
export async function trackCoUsage(fromNoteId: string, toNoteId: string): Promise<void> {
if (fromNoteId === toNoteId) return
try {
await prisma.noteCoUsage.upsert({
where: {
fromNoteId_toNoteId: { fromNoteId, toNoteId },
},
update: {
weight: { increment: 1 },
},
create: {
fromNoteId,
toNoteId,
weight: 1,
},
})
} catch {
// Silently fail
}
}
// Get notes that are often viewed together with a given note
export async function getCoUsedNotes(
noteId: string,
limit = 5,
days = 30
): Promise<{ noteId: string; title: string; type: string; weight: number }[]> {
try {
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
const coUsages = await prisma.noteCoUsage.findMany({
where: {
OR: [
{ fromNoteId: noteId },
{ toNoteId: noteId },
],
updatedAt: { gte: since },
},
orderBy: { weight: 'desc' },
take: limit,
})
const result: { noteId: string; title: string; type: string; weight: number }[] = []
for (const cu of coUsages) {
const relatedNoteId = cu.fromNoteId === noteId ? cu.toNoteId : cu.fromNoteId
const note = await prisma.note.findUnique({
where: { id: relatedNoteId },
select: { id: true, title: true, type: true },
})
if (note) {
result.push({
noteId: note.id,
title: note.title,
type: note.type,
weight: cu.weight,
})
}
}
return result
} catch {
return []
}
}

View File

@@ -2,6 +2,8 @@ import { z } from 'zod'
export const NoteTypeEnum = z.enum(['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note'])
export const CreationSourceEnum = z.enum(['form', 'quick', 'import'])
export const noteSchema = z.object({
id: z.string().optional(),
title: z.string().min(1, 'Title is required').max(200),
@@ -10,6 +12,7 @@ export const noteSchema = z.object({
isFavorite: z.boolean().default(false),
isPinned: z.boolean().default(false),
tags: z.array(z.string()).optional(),
creationSource: CreationSourceEnum.default('form'),
})
export const updateNoteSchema = noteSchema.partial().extend({