Add stats

This commit is contained in:
2026-02-23 21:09:27 +11:00
parent 73fdec8bba
commit 6be8332395
11 changed files with 558 additions and 20 deletions
+67 -1
View File
@@ -13,6 +13,7 @@ from app.engines import registry
from app.excluded import get_excluded_domains, add_excluded_domain, remove_excluded_domain
from app.settings import get_all_settings, get_setting, set_setting
from app.cache import is_cache_available, flush_cache, reconnect_redis
from app import stats as _stats
router = APIRouter()
@@ -65,6 +66,7 @@ curl '$BASE_URL/api/search?q=cats&category=images&image_size=large'
},
)
async def api_search(
request: Request,
q: str = Query(..., description="Search query string", min_length=1),
category: Literal["web", "images"] = Query("web", description="Search category"),
page: int = Query(1, ge=1, le=50, description="Page number"),
@@ -72,7 +74,18 @@ async def api_search(
image_size: Literal["", "large", "medium", "small"] = Query("", description="Filter images by size (images category only)"),
):
engine_list = [e.strip() for e in engines.split(",")] if engines else None
return await search(q, category=category, page=page, engines=engine_list, image_size=image_size)
result = await search(q, category=category, page=page, engines=engine_list, image_size=image_size)
origin_ip = request.client.host if request.client else ""
user_agent = request.headers.get("user-agent", "")
_stats.record_search(
query=q,
category=category,
origin_ip=origin_ip,
user_agent=user_agent,
result_count=result.total_results,
cached=result.cached,
)
return result
# --- Autocomplete ---
@@ -538,3 +551,56 @@ async def api_remove_bookmark_by_url(url: str):
if not removed:
return JSONResponse(status_code=404, content={"code": "not_found", "message": "Bookmark not found"})
return {"message": "Bookmark removed"}
# --- Analytics / Stats ---
class ClickEventRequest(BaseModel):
query: str
category: str = "web"
position: int = 0
url: str
title: str = ""
engine: str = ""
class StatsResponse(BaseModel):
period_days: int
total_searches: int
total_clicks: int
top_queries: list[dict]
top_clicked_urls: list[dict]
top_positions: list[dict]
engine_clicks: list[dict]
daily_searches: list[dict]
@router.post(
"/stats/click",
summary="Record a result click",
description="Called by the frontend when a user clicks a search result.",
tags=["Stats"],
)
async def api_record_click(body: ClickEventRequest, request: Request):
origin_ip = request.client.host if request.client else ""
_stats.record_click(
query=body.query,
category=body.category,
position=body.position,
url=body.url,
title=body.title,
engine=body.engine,
origin_ip=origin_ip,
)
return {"ok": True}
@router.get(
"/stats",
response_model=StatsResponse,
summary="Get search analytics summary",
description="Returns aggregated search and click statistics for the last N days.",
tags=["Stats"],
)
async def api_get_stats(days: int = Query(7, ge=1, le=365)):
return _stats.get_summary(days)
+2
View File
@@ -18,6 +18,7 @@ from app.excluded import init_db
from app.settings import init_settings_table
from app.cache import init_redis, close_redis
from app.bookmarks import init_bookmarks_table
from app.stats import init_db as init_stats_db
from app.models import APIError
@@ -26,6 +27,7 @@ async def lifespan(application: FastAPI):
init_db()
init_settings_table()
init_bookmarks_table()
init_stats_db()
registry.load_default_engines()
await init_redis()
yield
+171
View File
@@ -0,0 +1,171 @@
"""Search analytics persisted in a local SQLite database."""
from __future__ import annotations
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
_DB_PATH = Path(__file__).parent.parent / "data" / "stats.db"
_lock = threading.Lock()
def _conn() -> sqlite3.Connection:
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(str(_DB_PATH), check_same_thread=False)
con.row_factory = sqlite3.Row
return con
def init_db() -> None:
with _lock, _conn() as con:
con.executescript("""
CREATE TABLE IF NOT EXISTS search_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
query TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'web',
origin_ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
result_count INTEGER NOT NULL DEFAULT 0,
cached INTEGER NOT NULL DEFAULT 0,
ts TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_se_ts ON search_events(ts);
CREATE INDEX IF NOT EXISTS idx_se_query ON search_events(query COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS click_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
query TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'web',
position INTEGER NOT NULL DEFAULT 0,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
engine TEXT NOT NULL DEFAULT '',
origin_ip TEXT NOT NULL DEFAULT '',
ts TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ce_ts ON click_events(ts);
CREATE INDEX IF NOT EXISTS idx_ce_query ON click_events(query COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_ce_url ON click_events(url);
CREATE INDEX IF NOT EXISTS idx_ce_engine ON click_events(engine);
""")
# ---------------------------------------------------------------------------
# Write helpers
# ---------------------------------------------------------------------------
def record_search(
query: str,
category: str,
origin_ip: str,
user_agent: str,
result_count: int,
cached: bool,
) -> None:
ts = datetime.now(timezone.utc).isoformat()
with _lock, _conn() as con:
con.execute(
"INSERT INTO search_events (query, category, origin_ip, user_agent, result_count, cached, ts) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(query, category, origin_ip, user_agent, result_count, int(cached), ts),
)
def record_click(
query: str,
category: str,
position: int,
url: str,
title: str,
engine: str,
origin_ip: str,
) -> None:
ts = datetime.now(timezone.utc).isoformat()
with _lock, _conn() as con:
con.execute(
"INSERT INTO click_events (query, category, position, url, title, engine, origin_ip, ts) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(query, category, position, url, title, engine, origin_ip, ts),
)
# ---------------------------------------------------------------------------
# Read helpers
# ---------------------------------------------------------------------------
def get_summary(days: int = 7) -> dict:
"""Return aggregated stats for the last *days* days."""
cutoff = f"{datetime.now(timezone.utc).date().isoformat()}T00:00:00+00:00"
# Rough: use string comparison on ISO dates (works because format is fixed)
import datetime as dt
cutoff_dt = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)
cutoff = cutoff_dt.isoformat()
with _lock, _conn() as con:
total_searches = con.execute(
"SELECT COUNT(*) FROM search_events WHERE ts >= ?", (cutoff,)
).fetchone()[0]
total_clicks = con.execute(
"SELECT COUNT(*) FROM click_events WHERE ts >= ?", (cutoff,)
).fetchone()[0]
top_queries = [
{"query": r["query"], "count": r["cnt"]}
for r in con.execute(
"SELECT query, COUNT(*) AS cnt FROM search_events WHERE ts >= ? "
"GROUP BY query COLLATE NOCASE ORDER BY cnt DESC LIMIT 20",
(cutoff,),
).fetchall()
]
top_clicked_urls = [
{"url": r["url"], "title": r["title"], "engine": r["engine"], "count": r["cnt"]}
for r in con.execute(
"SELECT url, title, engine, COUNT(*) AS cnt FROM click_events WHERE ts >= ? "
"GROUP BY url ORDER BY cnt DESC LIMIT 20",
(cutoff,),
).fetchall()
]
top_positions = [
{"position": r["position"], "count": r["cnt"]}
for r in con.execute(
"SELECT position, COUNT(*) AS cnt FROM click_events WHERE ts >= ? "
"GROUP BY position ORDER BY position ASC LIMIT 20",
(cutoff,),
).fetchall()
]
engine_clicks = [
{"engine": r["engine"], "count": r["cnt"]}
for r in con.execute(
"SELECT engine, COUNT(*) AS cnt FROM click_events WHERE ts >= ? "
"GROUP BY engine ORDER BY cnt DESC",
(cutoff,),
).fetchall()
]
daily_searches = [
{"date": r["day"], "searches": r["cnt"]}
for r in con.execute(
"SELECT substr(ts, 1, 10) AS day, COUNT(*) AS cnt "
"FROM search_events WHERE ts >= ? GROUP BY day ORDER BY day DESC LIMIT 30",
(cutoff,),
).fetchall()
]
return {
"period_days": days,
"total_searches": total_searches,
"total_clicks": total_clicks,
"top_queries": top_queries,
"top_clicked_urls": top_clicked_urls,
"top_positions": top_positions,
"engine_clicks": engine_clicks,
"daily_searches": daily_searches,
}
+34 -9
View File
@@ -9,6 +9,7 @@ import { ErrorToast } from "@/components/ErrorToast";
import { BackgroundGallery } from "@/components/BackgroundGallery";
import { Bookmarks } from "@/components/Bookmarks";
import { AppHeader } from "@/components/AppHeader";
import { StatsPage } from "@/components/StatsPage";
import { search as apiSearch, isImageResult, getBackground, refreshBackground, getBookmarkedUrls, addBookmark, removeBookmarkByUrl, type SearchResponse, type WebResult, type ImageResult, type BackgroundInfo } from "@/lib/api";
import { cn } from "@/lib/utils";
@@ -58,6 +59,7 @@ function App() {
const [hasSearched, setHasSearched] = useState(!!initial.q);
const [showGallery, setShowGallery] = useState(window.location.pathname === "/backgrounds");
const [showBookmarks, setShowBookmarks] = useState(window.location.pathname === "/bookmarks");
const [showStats, setShowStats] = useState(window.location.pathname === "/stats");
const statusRef = useRef<HTMLDivElement>(null);
// Background image state
@@ -74,7 +76,7 @@ function App() {
}, []);
const bgUrl = bgInfo?.enabled && bgInfo?.url ? bgInfo.url : null;
const isHome = !hasSearched && !showGallery && !showBookmarks;
const isHome = !hasSearched && !showGallery && !showBookmarks && !showStats;
// iOS Safari fills top/bottom browser areas from page background color,
// so sample image edge colors to avoid white/black bars.
@@ -198,17 +200,20 @@ function App() {
useEffect(() => {
const onPopState = () => {
if (window.location.pathname === "/backgrounds") {
setShowGallery(true);
setShowBookmarks(false);
setShowGallery(true); setShowBookmarks(false); setShowStats(false);
return;
}
if (window.location.pathname === "/bookmarks") {
setShowBookmarks(true);
setShowGallery(false);
setShowBookmarks(true); setShowGallery(false); setShowStats(false);
return;
}
if (window.location.pathname === "/stats") {
setShowStats(true); setShowGallery(false); setShowBookmarks(false);
return;
}
setShowGallery(false);
setShowBookmarks(false);
setShowStats(false);
const { q, cat, page: p, imageSize: size } = parseUrlState();
if (q) {
doSearch(q, cat, p, size, false);
@@ -248,6 +253,7 @@ function App() {
setImageSize("");
setShowGallery(false);
setShowBookmarks(false);
setShowStats(false);
window.history.pushState(null, "", "/");
getBackground().then(setBgInfo).catch(() => {});
};
@@ -267,15 +273,24 @@ function App() {
const handleShowGallery = () => {
setShowGallery(true);
setShowBookmarks(false);
setShowStats(false);
window.history.pushState(null, "", "/backgrounds");
};
const handleShowBookmarks = () => {
setShowBookmarks(true);
setShowGallery(false);
setShowStats(false);
window.history.pushState(null, "", "/bookmarks");
};
const handleShowStats = () => {
setShowStats(true);
setShowGallery(false);
setShowBookmarks(false);
window.history.pushState(null, "", "/stats");
};
const handleToggleBookmark = async (result: WebResult | ImageResult) => {
const isImage = "img_src" in result;
const url = result.url;
@@ -313,7 +328,7 @@ function App() {
// Gallery page
if (showGallery) {
return <>
<BackgroundGallery onBack={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowBookmarks={handleShowBookmarks} />
<BackgroundGallery onBack={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowBookmarks={handleShowBookmarks} onShowStats={handleShowStats} />
{settingsModal}
</>;
}
@@ -321,7 +336,15 @@ function App() {
// Bookmarks page
if (showBookmarks) {
return <>
<Bookmarks onGoHome={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowGallery={handleShowGallery} />
<Bookmarks onGoHome={handleGoHome} onShowSettings={() => setShowSettings(true)} onShowGallery={handleShowGallery} onShowStats={handleShowStats} />
{settingsModal}
</>;
}
// Stats page
if (showStats) {
return <>
<StatsPage onGoHome={handleGoHome} />
{settingsModal}
</>;
}
@@ -351,6 +374,7 @@ function App() {
onShowSettings={() => setShowSettings(true)}
onShowBookmarks={handleShowBookmarks}
onShowGallery={handleShowGallery}
onShowStats={handleShowStats}
transparent={!!bgUrl}
hideLogo
/>
@@ -464,6 +488,7 @@ function App() {
onShowSettings={() => setShowSettings(true)}
onShowBookmarks={handleShowBookmarks}
onShowGallery={handleShowGallery}
onShowStats={handleShowStats}
>
<SearchBar initialQuery={query} onSearch={(q) => doSearch(q)} />
</AppHeader>
@@ -549,8 +574,8 @@ function App() {
<div className="flex gap-6">
{/* Results column */}
<div className="min-w-0 flex-1">
{category === "web" && <WebResults results={webResults} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
{category === "images" && <ImageResults results={imageResults} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
{category === "web" && <WebResults results={webResults} query={query} category={category} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
{category === "images" && <ImageResults results={imageResults} query={query} category={category} bookmarkedUrls={bookmarkedUrls} onToggleBookmark={handleToggleBookmark} />}
{/* Pagination */}
{hasResults && (
+9 -5
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { Menu, X, Settings, BookmarkIcon, Images, ExternalLink } from "lucide-react";
import { Menu, X, Settings, BookmarkIcon, Images, ExternalLink, BarChart2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface AppHeaderProps {
@@ -7,6 +7,7 @@ interface AppHeaderProps {
onShowSettings: () => void;
onShowBookmarks: () => void;
onShowGallery: () => void;
onShowStats: () => void;
/** Hides the logo button (used on the home page) */
hideLogo?: boolean;
/** Transparent header overlay (home page with background image) */
@@ -18,10 +19,11 @@ interface AppHeaderProps {
// Items ordered left→right. Rightmost item (Settings) gets the shortest delay,
// so it appears first creating the right-to-left unfold effect.
const NAV_ITEMS = [
{ id: "docs", icon: ExternalLink, label: "API Docs", href: "/docs" },
{ id: "gallery", icon: Images, label: "Backgrounds", href: null },
{ id: "bookmarks", icon: BookmarkIcon, label: "Bookmarks", href: null },
{ id: "settings", icon: Settings, label: "Settings", href: null },
{ id: "docs", icon: ExternalLink, label: "API Docs", href: "/docs" },
{ id: "stats", icon: BarChart2, label: "Stats", href: null },
{ id: "gallery", icon: Images, label: "Backgrounds", href: null },
{ id: "bookmarks", icon: BookmarkIcon, label: "Bookmarks", href: null },
{ id: "settings", icon: Settings, label: "Settings", href: null },
] as const;
const STAGGER_MS = 60;
@@ -31,6 +33,7 @@ export function AppHeader({
onShowSettings,
onShowBookmarks,
onShowGallery,
onShowStats,
hideLogo = false,
transparent = false,
children,
@@ -44,6 +47,7 @@ export function AppHeader({
settings: () => { onShowSettings(); close(); },
bookmarks: () => { onShowBookmarks(); close(); },
gallery: () => { onShowGallery(); close(); },
stats: () => { onShowStats(); close(); },
docs: close,
};
@@ -7,9 +7,10 @@ interface BackgroundGalleryProps {
onBack: () => void;
onShowSettings: () => void;
onShowBookmarks: () => void;
onShowStats: () => void;
}
export function BackgroundGallery({ onBack, onShowSettings, onShowBookmarks }: BackgroundGalleryProps) {
export function BackgroundGallery({ onBack, onShowSettings, onShowBookmarks, onShowStats }: BackgroundGalleryProps) {
const [images, setImages] = useState<BackgroundListItem[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
@@ -51,6 +52,7 @@ export function BackgroundGallery({ onBack, onShowSettings, onShowBookmarks }: B
onShowSettings={onShowSettings}
onShowBookmarks={onShowBookmarks}
onShowGallery={() => {}}
onShowStats={onShowStats}
>
<h1 className="text-base font-semibold">
Background Gallery
+3 -1
View File
@@ -10,9 +10,10 @@ interface BookmarksProps {
onGoHome: () => void;
onShowSettings: () => void;
onShowGallery: () => void;
onShowStats: () => void;
}
export function Bookmarks({ onGoHome, onShowSettings, onShowGallery }: BookmarksProps) {
export function Bookmarks({ onGoHome, onShowSettings, onShowGallery, onShowStats }: BookmarksProps) {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
@@ -64,6 +65,7 @@ export function Bookmarks({ onGoHome, onShowSettings, onShowGallery }: Bookmarks
onShowSettings={onShowSettings}
onShowBookmarks={() => {}}
onShowGallery={onShowGallery}
onShowStats={onShowStats}
>
<h1 className="text-base font-semibold">
Bookmarks
+6 -2
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef, type MouseEvent } from "react";
import type { ImageResult } from "@/lib/api";
import { trackClick } from "@/lib/api";
import { X, ChevronLeft, ChevronRight, Bookmark, BookmarkCheck } from "lucide-react";
function formatSize(w: number, h: number): string {
@@ -8,11 +9,13 @@ function formatSize(w: number, h: number): string {
interface ImageResultsProps {
results: ImageResult[];
query?: string;
category?: string;
bookmarkedUrls?: Set<string>;
onToggleBookmark?: (result: ImageResult) => void;
}
export function ImageResults({ results, bookmarkedUrls, onToggleBookmark }: ImageResultsProps) {
export function ImageResults({ results, query = "", category = "images", bookmarkedUrls, onToggleBookmark }: ImageResultsProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
// Track detected dimensions per result index
const [dims, setDims] = useState<Record<number, { w: number; h: number }>>({});
@@ -98,7 +101,7 @@ export function ImageResults({ results, bookmarkedUrls, onToggleBookmark }: Imag
target="_blank"
rel="noopener noreferrer"
role="listitem"
onClick={(e) => handleCardClick(e, i)}
onClick={(e) => { handleCardClick(e, i); trackClick({ query, category, position: i + 1, url: img.url, title: img.title, engine: img.engine }); }}
className="group relative mb-3 inline-block w-full overflow-hidden rounded-lg border bg-muted break-inside-avoid hover:ring-2 hover:ring-ring focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none transition-shadow"
>
<img
@@ -207,6 +210,7 @@ export function ImageResults({ results, bookmarkedUrls, onToggleBookmark }: Imag
href={selected.url}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackClick({ query, category, position: selectedIndex + 1, url: selected.url, title: selected.title, engine: selected.engine })}
className="mt-2 inline-block text-sm text-blue-600 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none dark:text-blue-400"
>
Visit page
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import { fetchStats, type StatsSummary } from "@/lib/api";
import { BarChart2, TrendingUp, MousePointerClick, Search } from "lucide-react";
interface StatsPageProps {
onGoHome: () => void;
}
function Card({ title, value, sub }: { title: string; value: string | number; sub?: string }) {
return (
<div className="rounded-xl border bg-card p-5 shadow-sm">
<p className="text-sm text-muted-foreground">{title}</p>
<p className="mt-1 text-3xl font-bold">{value}</p>
{sub && <p className="mt-1 text-xs text-muted-foreground">{sub}</p>}
</div>
);
}
export function StatsPage({ onGoHome }: StatsPageProps) {
const [days, setDays] = useState(7);
const [data, setData] = useState<StatsSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
setLoading(true);
setError("");
fetchStats(days)
.then(setData)
.catch(() => setError("Failed to load stats."))
.finally(() => setLoading(false));
}, [days]);
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur">
<div className="flex items-center gap-3 px-4 py-3">
<button
onClick={onGoHome}
className="text-xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none rounded"
>
HS
</button>
<div className="flex items-center gap-2 text-sm font-medium">
<BarChart2 className="h-4 w-4" />
Search Analytics
</div>
<div className="ml-auto flex items-center gap-1">
{[7, 30, 90].map((d) => (
<button
key={d}
onClick={() => setDays(d)}
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none ${
days === d ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-accent"
}`}
>
{d}d
</button>
))}
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-8 space-y-8">
{loading && <p className="text-center text-muted-foreground py-16">Loading</p>}
{error && <p className="text-center text-destructive py-16">{error}</p>}
{data && (
<>
{/* KPI row */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card title="Searches" value={data.total_searches.toLocaleString()} sub={`last ${days} days`} />
<Card title="Clicks" value={data.total_clicks.toLocaleString()} sub={`last ${days} days`} />
<Card
title="CTR"
value={data.total_searches > 0 ? `${((data.total_clicks / data.total_searches) * 100).toFixed(1)}%` : "—"}
sub="clicks / searches"
/>
<Card
title="Avg position"
value={
data.top_positions.length > 0
? (
data.top_positions.reduce((s, p) => s + p.position * p.count, 0) /
data.top_positions.reduce((s, p) => s + p.count, 0)
).toFixed(1)
: "—"
}
sub="clicked result rank"
/>
</div>
{/* Daily trend */}
{data.daily_searches.length > 0 && (
<section>
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold">
<TrendingUp className="h-4 w-4" /> Daily Searches
</h2>
<div className="rounded-xl border bg-card p-4">
<div className="flex items-end gap-1 h-28">
{[...data.daily_searches].reverse().map((d) => {
const max = Math.max(...data.daily_searches.map((x) => x.searches), 1);
const pct = (d.searches / max) * 100;
return (
<div key={d.date} className="flex flex-1 flex-col items-center gap-1 min-w-0" title={`${d.date}: ${d.searches}`}>
<div
className="w-full rounded-sm bg-primary/70 transition-all"
style={{ height: `${Math.max(pct, 2)}%` }}
/>
<span className="text-[9px] text-muted-foreground truncate w-full text-center hidden sm:block">
{d.date.slice(5)}
</span>
</div>
);
})}
</div>
</div>
</section>
)}
<div className="grid gap-6 md:grid-cols-2">
{/* Top queries */}
<section>
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold">
<Search className="h-4 w-4" /> Top Queries
</h2>
<div className="rounded-xl border bg-card divide-y">
{data.top_queries.length === 0 && (
<p className="px-4 py-6 text-sm text-center text-muted-foreground">No data yet</p>
)}
{data.top_queries.map((q, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<span className="w-5 shrink-0 text-center text-xs text-muted-foreground">{i + 1}</span>
<span className="flex-1 truncate font-medium">{q.query}</span>
<span className="tabular-nums text-muted-foreground">{q.count}</span>
</div>
))}
</div>
</section>
{/* Engine clicks */}
<section>
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold">
<MousePointerClick className="h-4 w-4" /> Clicks by Engine
</h2>
<div className="rounded-xl border bg-card divide-y">
{data.engine_clicks.length === 0 && (
<p className="px-4 py-6 text-sm text-center text-muted-foreground">No data yet</p>
)}
{data.engine_clicks.map((e, i) => {
const total = data.engine_clicks.reduce((s, x) => s + x.count, 0);
const pct = total > 0 ? Math.round((e.count / total) * 100) : 0;
return (
<div key={i} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<span className="flex-1 capitalize font-medium">{e.engine || "unknown"}</span>
<div className="flex items-center gap-2">
<div className="w-20 h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className="tabular-nums text-muted-foreground w-8 text-right">{e.count}</span>
</div>
</div>
);
})}
</div>
</section>
</div>
{/* Position distribution */}
{data.top_positions.length > 0 && (
<section>
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold">
<MousePointerClick className="h-4 w-4" /> Click Position Distribution
</h2>
<div className="rounded-xl border bg-card p-4">
<div className="flex items-end gap-2 h-24">
{data.top_positions.map((p) => {
const max = Math.max(...data.top_positions.map((x) => x.count), 1);
const pct = (p.count / max) * 100;
return (
<div key={p.position} className="flex flex-1 flex-col items-center gap-1" title={`#${p.position}: ${p.count} clicks`}>
<div
className="w-full rounded-sm bg-blue-500/70 transition-all"
style={{ height: `${Math.max(pct, 3)}%` }}
/>
<span className="text-[10px] text-muted-foreground">#{p.position}</span>
</div>
);
})}
</div>
</div>
</section>
)}
{/* Top clicked URLs */}
<section>
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold">
<MousePointerClick className="h-4 w-4" /> Top Clicked Results
</h2>
<div className="rounded-xl border bg-card divide-y">
{data.top_clicked_urls.length === 0 && (
<p className="px-4 py-6 text-sm text-center text-muted-foreground">No clicks recorded yet</p>
)}
{data.top_clicked_urls.map((r, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<span className="w-5 shrink-0 text-center text-xs text-muted-foreground">{i + 1}</span>
<div className="flex-1 min-w-0">
<p className="truncate font-medium">{r.title || r.url}</p>
<p className="truncate text-xs text-muted-foreground">{r.url}</p>
</div>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs capitalize">{r.engine}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">{r.count}</span>
</div>
))}
</div>
</section>
</>
)}
</main>
</div>
);
}
+5 -1
View File
@@ -1,13 +1,16 @@
import type { WebResult } from "@/lib/api";
import { trackClick } from "@/lib/api";
import { ExternalLink, Bookmark, BookmarkCheck } from "lucide-react";
interface WebResultsProps {
results: WebResult[];
query?: string;
category?: string;
bookmarkedUrls?: Set<string>;
onToggleBookmark?: (result: WebResult) => void;
}
export function WebResults({ results, bookmarkedUrls, onToggleBookmark }: WebResultsProps) {
export function WebResults({ results, query = "", category = "web", bookmarkedUrls, onToggleBookmark }: WebResultsProps) {
if (results.length === 0) return null;
return (
@@ -43,6 +46,7 @@ export function WebResults({ results, bookmarkedUrls, onToggleBookmark }: WebRes
href={result.url}
target="_blank"
rel="noopener noreferrer"
onClick={() => trackClick({ query, category, position: i + 1, url: result.url, title: result.title, engine: result.engine })}
className="mt-1 block text-lg font-medium text-blue-600 visited:text-purple-600 hover:underline dark:text-blue-400 dark:visited:text-purple-400 sm:text-xl"
>
{result.title}
+35
View File
@@ -279,3 +279,38 @@ export async function removeBookmarkByUrl(url: string): Promise<void> {
const resp = await fetch(`${API_BASE}/bookmarks/by-url/${encodeURIComponent(url)}`, { method: "DELETE" });
if (!resp.ok) throw new Error("Failed to remove bookmark");
}
// --- Analytics ---
export interface StatsSummary {
period_days: number;
total_searches: number;
total_clicks: number;
top_queries: { query: string; count: number }[];
top_clicked_urls: { url: string; title: string; engine: string; count: number }[];
top_positions: { position: number; count: number }[];
engine_clicks: { engine: string; count: number }[];
daily_searches: { date: string; searches: number }[];
}
export async function trackClick(data: {
query: string;
category: string;
position: number;
url: string;
title: string;
engine: string;
}): Promise<void> {
// fire-and-forget — don't block the user
fetch(`${API_BASE}/stats/click`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).catch(() => {});
}
export async function fetchStats(days = 7): Promise<StatsSummary> {
const resp = await fetch(`${API_BASE}/stats?days=${days}`);
if (!resp.ok) throw new Error("Failed to fetch stats");
return resp.json();
}