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:
@@ -8,7 +8,8 @@ import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X } from 'lucide-react'
|
||||
import { X, Sparkles } from 'lucide-react'
|
||||
import { inferNoteType } from '@/lib/type-inference'
|
||||
|
||||
// Command fields
|
||||
interface CommandFields {
|
||||
@@ -615,6 +616,7 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
})
|
||||
const [tags, setTags] = useState<string[]>(initialData?.tags.map(t => t.tag.name) || [])
|
||||
const [autoSuggestedTags, setAutoSuggestedTags] = useState<string[]>([])
|
||||
const [autoSuggestedType, setAutoSuggestedType] = useState<NoteType | null>(null)
|
||||
const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false)
|
||||
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -653,6 +655,84 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [title, content, tags])
|
||||
|
||||
// Auto-suggest type based on content (only for new notes, not edits)
|
||||
useEffect(() => {
|
||||
if (isEdit || !content.trim()) {
|
||||
setAutoSuggestedType(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Only suggest if content is reasonably filled and user hasn't changed type manually
|
||||
const suggestion = inferNoteType(content)
|
||||
if (suggestion && suggestion.confidence === 'high') {
|
||||
setAutoSuggestedType(suggestion.type)
|
||||
} else {
|
||||
setAutoSuggestedType(null)
|
||||
}
|
||||
}, [content, isEdit])
|
||||
|
||||
const acceptSuggestedType = () => {
|
||||
if (autoSuggestedType) {
|
||||
setType(autoSuggestedType)
|
||||
setFields(defaultFields[autoSuggestedType])
|
||||
setAutoSuggestedType(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Link suggestions state
|
||||
const [linkSuggestions, setLinkSuggestions] = useState<{ term: string; noteId: string; noteTitle: string }[]>([])
|
||||
|
||||
// Fetch link suggestions based on content
|
||||
useEffect(() => {
|
||||
const fetchLinkSuggestions = async () => {
|
||||
if (content.trim().length < 20) {
|
||||
setLinkSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ content })
|
||||
if (initialData?.id) {
|
||||
params.set('noteId', initialData.id)
|
||||
}
|
||||
const res = await fetch(`/api/notes/links?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setLinkSuggestions(data.data || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching link suggestions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(fetchLinkSuggestions, 800)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [content, initialData?.id])
|
||||
|
||||
const convertToWikiLink = (term: string) => {
|
||||
// Replace the term with [[term]] in the content
|
||||
// This requires modifying fields directly based on the type
|
||||
const newContent = content.replace(new RegExp(`\\b(${escapeRegex(term)})\\b`, 'gi'), `[[${term}]]`)
|
||||
// Update the appropriate field based on type
|
||||
if (type === 'note') {
|
||||
setFields({ content: newContent })
|
||||
} else if (type === 'command') {
|
||||
const f = fields as CommandFields
|
||||
setFields({ ...f, example: newContent })
|
||||
} else if (type === 'snippet') {
|
||||
const f = fields as SnippetFields
|
||||
setFields({ ...f, code: newContent })
|
||||
} else {
|
||||
setFields({ content: newContent } as TypeFields)
|
||||
}
|
||||
// Remove from suggestions
|
||||
setLinkSuggestions(prev => prev.filter(s => s.term !== term))
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
@@ -733,6 +813,21 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{autoSuggestedType && autoSuggestedType !== type && (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<Sparkles className="h-3 w-3 text-primary" />
|
||||
<span className="text-muted-foreground">
|
||||
¿Es <span className="text-primary font-medium">{autoSuggestedType}</span>?
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={acceptSuggestedType}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Usar tipo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -769,6 +864,30 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link suggestions */}
|
||||
{linkSuggestions.length > 0 && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Enlaces internos detectados:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{linkSuggestions.slice(0, 5).map((suggestion) => (
|
||||
<button
|
||||
key={suggestion.term}
|
||||
type="button"
|
||||
onClick={() => convertToWikiLink(suggestion.term)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-sm bg-background border rounded-full hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
title={`Convertir "${suggestion.term}" a [[${suggestion.term}]]`}
|
||||
>
|
||||
<span>{suggestion.term}</span>
|
||||
<span className="text-xs opacity-60">[[]]</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
|
||||
@@ -1,19 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Plus, Loader2 } from 'lucide-react'
|
||||
import { Plus, Loader2, Text, Sparkles, X } from 'lucide-react'
|
||||
import { inferNoteType, formatContentForType } from '@/lib/type-inference'
|
||||
import { NoteType } from '@/types/note'
|
||||
|
||||
interface TypeSuggestion {
|
||||
type: NoteType
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
reason: string
|
||||
formattedContent: string
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<NoteType, string> = {
|
||||
command: 'Comando',
|
||||
snippet: 'Snippet',
|
||||
procedure: 'Procedimiento',
|
||||
recipe: 'Receta',
|
||||
decision: 'Decisión',
|
||||
inventory: 'Inventario',
|
||||
note: 'Nota',
|
||||
}
|
||||
|
||||
export function QuickAdd() {
|
||||
const [value, setValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isMultiline, setIsMultiline] = useState(false)
|
||||
const [typeSuggestion, setTypeSuggestion] = useState<TypeSuggestion | null>(null)
|
||||
const [dismissedSuggestion, setDismissedSuggestion] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const detectContentType = useCallback((text: string) => {
|
||||
if (!text.trim() || text.length < 10) {
|
||||
setTypeSuggestion(null)
|
||||
return
|
||||
}
|
||||
if (dismissedSuggestion) return
|
||||
|
||||
const suggestion = inferNoteType(text)
|
||||
if (suggestion && suggestion.confidence === 'high') {
|
||||
const formatted = formatContentForType(text, suggestion.type)
|
||||
setTypeSuggestion({
|
||||
...suggestion,
|
||||
formattedContent: formatted,
|
||||
})
|
||||
} else {
|
||||
setTypeSuggestion(null)
|
||||
}
|
||||
}, [dismissedSuggestion])
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
// Let the paste happen first
|
||||
setDismissedSuggestion(false)
|
||||
setTimeout(() => {
|
||||
detectContentType(value)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const acceptSuggestion = () => {
|
||||
if (typeSuggestion) {
|
||||
setValue(typeSuggestion.formattedContent)
|
||||
setTypeSuggestion(null)
|
||||
setIsMultiline(true)
|
||||
setIsExpanded(true)
|
||||
}
|
||||
}
|
||||
|
||||
const dismissSuggestion = () => {
|
||||
setTypeSuggestion(null)
|
||||
setDismissedSuggestion(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!value.trim() || isLoading) return
|
||||
@@ -48,14 +113,24 @@ export function QuickAdd() {
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isMultiline) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setValue('')
|
||||
setIsExpanded(false)
|
||||
setIsMultiline(false)
|
||||
inputRef.current?.blur()
|
||||
textareaRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMultiline = () => {
|
||||
setIsMultiline(!isMultiline)
|
||||
if (!isMultiline) {
|
||||
setIsExpanded(true)
|
||||
setTimeout(() => textareaRef.current?.focus(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,42 +155,117 @@ export function QuickAdd() {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="cmd: título #tag..."
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
className={cn(
|
||||
'w-48 transition-all duration-200',
|
||||
isExpanded && 'w-72'
|
||||
<div className="relative">
|
||||
<form onSubmit={handleSubmit} className="flex items-end gap-2">
|
||||
<div className="relative flex-1">
|
||||
{isMultiline ? (
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder="cmd: título #tag Contenido multilínea..."
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
detectContentType(e.target.value)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
onPaste={handlePaste}
|
||||
className={cn(
|
||||
'min-h-[80px] max-h-[200px] transition-all duration-200 resize-none',
|
||||
isExpanded && 'w-full'
|
||||
)}
|
||||
disabled={isLoading}
|
||||
rows={isExpanded ? 4 : 2}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="cmd: título #tag..."
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
onPaste={handlePaste}
|
||||
className={cn(
|
||||
'w-48 transition-all duration-200',
|
||||
isExpanded && 'w-72'
|
||||
)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim() || isLoading}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-lg border bg-background p-2',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMultiline}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-lg border bg-background p-2',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-colors',
|
||||
isMultiline && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
title={isMultiline ? 'Modo línea' : 'Modo multilínea'}
|
||||
>
|
||||
<Text className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!value.trim() || isLoading}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-lg border bg-background p-2',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'transition-colors'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Smart paste suggestion */}
|
||||
{typeSuggestion && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 p-3 bg-popover border rounded-lg shadow-md z-50">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
Detectado: <span className="text-primary">{TYPE_LABELS[typeSuggestion.type]}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{typeSuggestion.reason}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissSuggestion}
|
||||
className="p-1 hover:bg-accent rounded"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={acceptSuggestion}
|
||||
className="text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90"
|
||||
>
|
||||
Usar plantilla
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissSuggestion}
|
||||
className="text-xs px-2 py-1 text-muted-foreground hover:bg-accent rounded"
|
||||
>
|
||||
Descartar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { addToRecentlyViewed } from '@/lib/usage'
|
||||
import { addToRecentlyViewed, getRecentlyViewedIds } from '@/lib/usage'
|
||||
|
||||
export function TrackNoteView({ noteId }: { noteId: string }) {
|
||||
useEffect(() => {
|
||||
addToRecentlyViewed(noteId)
|
||||
|
||||
// Track co-usage with previously viewed notes
|
||||
const recentIds = getRecentlyViewedIds()
|
||||
// Track co-usage with up to 3 most recent notes (excluding current)
|
||||
const previousNotes = recentIds.filter(id => id !== noteId).slice(0, 3)
|
||||
|
||||
for (const prevNoteId of previousNotes) {
|
||||
fetch('/api/usage/co-usage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fromNoteId: prevNoteId, toNoteId: noteId }),
|
||||
}).catch(() => {}) // Silently fail
|
||||
}
|
||||
}, [noteId])
|
||||
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user