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:
30
src/app/api/usage/route.ts
Normal file
30
src/app/api/usage/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
12
src/components/track-note-view.tsx
Normal file
12
src/components/track-note-view.tsx
Normal 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
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