feat: MVP-3 Sprint 1 - Usage tracking, smart dashboard, scoring boost

## Registro de Uso
- Nuevo modelo NoteUsage en Prisma
- Tipos de eventos: view, search_click, related_click, link_click, copy_command, copy_snippet
- Funciones: trackNoteUsage, getUsageStats, getRecentlyUsedNotes
- localStorage: recentlyViewed (últimas 10 notas)
- Rastreo de copias en markdown-content.tsx

## Dashboard Rediseñado
- 5 bloques: Recientes, Más usadas, Comandos recientes, Snippets recientes, Según actividad
- Nuevo src/lib/dashboard.ts con getDashboardData()
- Recomendaciones basadas en recentlyViewed

## Scoring con Uso Real
- search.ts: +1 per 5 views (max +3), +2 recency boost
- related.ts: mismo sistema de usage boost
- No eclipsa match textual fuerte

## Tests
- 110 tests pasando (usage, dashboard, related, search)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 16:03:14 -03:00
parent cc4b2453b1
commit 05b8f3910d
16 changed files with 2038 additions and 73 deletions

103
src/lib/dashboard.ts Normal file
View 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
)
}

View File

@@ -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({

View File

@@ -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
View 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
}
}