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

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

View File

@@ -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>
</>
)
}

View File

@@ -1,37 +1,19 @@
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>
)
}
}

View File

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

View File

@@ -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>
)
},

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