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:
103
src/lib/dashboard.ts
Normal file
103
src/lib/dashboard.ts
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
138
src/lib/usage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user