+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/view-tracker.tsx b/src/components/view-tracker.tsx
new file mode 100644
index 0000000..9c4e861
--- /dev/null
+++ b/src/components/view-tracker.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import { useEffect } from "react"
+
+// Registra una vista de un post al abrirlo (dedupe por cookie en el servidor:
+// 1 vista por visitante por gid por día). No bloquea ni afecta la UI.
+export function ViewTracker({ gid }: { gid: string }) {
+ useEffect(() => {
+ if (!gid) return
+ // Fire-and-forget: no esperamos respuesta, no importa si falla.
+ fetch("/api/stats/view", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ gid }),
+ credentials: "same-origin",
+ }).catch(() => {})
+ }, [gid])
+
+ return null
+}
\ No newline at end of file
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 3e159c2..f4d8ddb 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -117,6 +117,19 @@ function migrate(db: Database.Database) {
try {
db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT")
} catch {}
+
+ // ===== Métricas de popularidad =====
+ // post_views: cada lectura de un post (1 por visitante por día, dedup por cookie).
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS post_views (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ gid TEXT NOT NULL,
+ viewed_at TEXT DEFAULT (datetime('now'))
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_views_gid ON post_views(gid);
+ CREATE INDEX IF NOT EXISTS idx_views_date ON post_views(viewed_at);
+ `)
}
export interface Post {
@@ -534,3 +547,53 @@ export function getTagsWithCounts(limit = 25): { tag: string; count: number }[]
return []
}
}
+
+// ===== Métricas de popularidad =====
+
+export interface ViewedPost extends Post {
+ views: number
+}
+
+// Registra una vista de un post (1 por visitante por día — el dedupe lo hace
+// el endpoint vía cookie; acá solo insertamos el evento).
+export function recordView(gid: string): void {
+ const d = getDb()
+ d.prepare("INSERT INTO post_views (gid) VALUES (?)").run(gid)
+}
+
+// Posts más LEÍDOS (total acumulado de vistas).
+export function getMostViewedPosts(limit = 10): ViewedPost[] {
+ const d = getDb()
+ const rows = d
+ .prepare(
+ `SELECT p.*, (SELECT COUNT(*) FROM post_views v WHERE v.gid = p.gid) as views
+ FROM posts p
+ WHERE p.published = 1
+ ORDER BY views DESC
+ LIMIT @limit`,
+ )
+ .all({ limit }) as Record[]
+ return rows.map((r) => ({ ...rowToPost(r), views: (r.views as number) || 0 }))
+}
+
+// Posts más VIRALES (vistas en los últimos N días) — lo que está explotando ahora.
+export function getTrendingPosts(days = 7, limit = 10): ViewedPost[] {
+ const d = getDb()
+ const rows = d
+ .prepare(
+ `SELECT p.*, (SELECT COUNT(*) FROM post_views v WHERE v.gid = p.gid AND v.viewed_at >= datetime('now', @daysExpr)) as views
+ FROM posts p
+ WHERE p.published = 1
+ ORDER BY views DESC
+ LIMIT @limit`,
+ )
+ .all({ daysExpr: `-${days} days`, limit }) as Record[]
+ return rows.map((r) => ({ ...rowToPost(r), views: (r.views as number) || 0 }))
+}
+
+// Cantidad de vistas de un gid (para el badge de la card).
+export function getPostViews(gid: string): number {
+ const d = getDb()
+ const row = d.prepare("SELECT COUNT(*) as c FROM post_views WHERE gid = ?").get(gid) as { c: number }
+ return row.c
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index 4c399a1..dbcf128 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -53,6 +53,9 @@ function isPublicPath(pathname: string, method: string): boolean {
// Solo GET; el token valida la autorización dentro del handler.
if (pathname.startsWith("/api/review/") && method === "GET") return true
+ // /api/stats/view: registro de vistas (POST, gid validado en el handler).
+ if (pathname === "/api/stats/view" && method === "POST") return true
+
return false
}