api: count page views and feature clicks

One events row per beacon: the kind, the page, the clicked control, a salted
hash of the address (never the address), its coarse place, and the browser,
system and device read off the UA. A new public POST /api/events writes it and
always answers 204; GET /api/admin/stats reads it back as totals, a day series
and one grouped breakdown per dimension, behind the admin gate.
This commit is contained in:
2026-09-18 14:23:23 +07:00
parent 1c4cdf8af1
commit 8a7192eae8
2 changed files with 278 additions and 1 deletions
+143
View File
@@ -60,9 +60,25 @@ CREATE TABLE IF NOT EXISTS photos (
bytes INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
at TEXT NOT NULL,
kind TEXT NOT NULL,
path TEXT NOT NULL,
target TEXT,
visitor TEXT NOT NULL,
user_id INTEGER,
country TEXT,
region TEXT,
city TEXT,
browser TEXT,
os TEXT,
device TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id);
CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id);
CREATE INDEX IF NOT EXISTS idx_events_at ON events(at);
`);
// Where a curated photo is allowed to appear on the landing page: the community
@@ -531,3 +547,130 @@ export function deleteAllPhotos(): string[] {
db.prepare('DELETE FROM photos').run();
return files;
}
// --- analytics -------------------------------------------------------------
// One row per page view or feature click. Nothing that identifies a visitor is
// stored: the address is turned into a salted hash (enough to count uniques)
// and into a coarse place at insert time, then dropped. See `createEvent`.
export type EventKind = 'view' | 'click';
export interface EventInput {
kind: EventKind;
path: string;
target: string | null;
visitor: string;
userId: number | null;
country: string | null;
region: string | null;
city: string | null;
browser: string | null;
os: string | null;
device: string | null;
}
export function createEvent(e: EventInput): void {
db.prepare(
`INSERT INTO events (at, kind, path, target, visitor, user_id, country, region, city, browser, os, device)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
new Date().toISOString(),
e.kind,
e.path,
e.target,
e.visitor,
e.userId,
e.country,
e.region,
e.city,
e.browser,
e.os,
e.device,
);
}
// One grouped count, in the shape every chart on the stats page wants.
export interface EventBucket {
key: string;
n: number;
}
export interface EventStats {
days: number;
totals: { views: number; clicks: number; visitors: number };
series: { date: string; views: number; clicks: number }[];
pages: EventBucket[];
targets: EventBucket[];
countries: EventBucket[];
regions: EventBucket[];
cities: EventBucket[];
browsers: EventBucket[];
systems: EventBucket[];
devices: EventBucket[];
}
// The columns a group-by may name. An allowlist, not interpolation: the value
// reaches a SQL string, so it must never come from the request unchecked.
const BUCKET_COLUMN = {
pages: 'path',
targets: 'target',
countries: 'country',
regions: 'region',
cities: 'city',
browsers: 'browser',
systems: 'os',
devices: 'device',
} as const;
export function eventStats(days: number, limit = 12): EventStats {
const since = new Date(Date.now() - days * 86_400_000).toISOString();
const grouped = (column: string, kind: EventKind | null): EventBucket[] =>
db
.prepare(
`SELECT ${column} AS key, COUNT(*) AS n FROM events
WHERE at >= ? AND ${column} IS NOT NULL AND ${column} <> ''
AND (? IS NULL OR kind = ?)
GROUP BY ${column} ORDER BY n DESC, key ASC LIMIT ?`,
)
.all(since, kind, kind, limit) as EventBucket[];
const totals = db
.prepare(
`SELECT
SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views,
SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks,
COUNT(DISTINCT visitor) AS visitors
FROM events WHERE at >= ?`,
)
.get(since) as { views: number | null; clicks: number | null; visitors: number };
// One point per calendar day (UTC), including days with no traffic, so the
// chart's x-axis is a real timeline and not just the days that had hits.
const seen = new Map<string, { date: string; views: number; clicks: number }>();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(Date.now() - i * 86_400_000).toISOString().slice(0, 10);
seen.set(date, { date, views: 0, clicks: 0 });
}
const rows = db
.prepare(
`SELECT substr(at, 1, 10) AS date,
SUM(CASE WHEN kind = 'view' THEN 1 ELSE 0 END) AS views,
SUM(CASE WHEN kind = 'click' THEN 1 ELSE 0 END) AS clicks
FROM events WHERE at >= ? GROUP BY date`,
)
.all(since) as { date: string; views: number; clicks: number }[];
for (const row of rows) if (seen.has(row.date)) seen.set(row.date, row);
return {
days,
totals: { views: totals.views ?? 0, clicks: totals.clicks ?? 0, visitors: totals.visitors },
series: [...seen.values()],
pages: grouped(BUCKET_COLUMN.pages, 'view'),
targets: grouped(BUCKET_COLUMN.targets, 'click'),
countries: grouped(BUCKET_COLUMN.countries, 'view'),
regions: grouped(BUCKET_COLUMN.regions, 'view'),
cities: grouped(BUCKET_COLUMN.cities, 'view'),
browsers: grouped(BUCKET_COLUMN.browsers, 'view'),
systems: grouped(BUCKET_COLUMN.systems, 'view'),
devices: grouped(BUCKET_COLUMN.devices, 'view'),
};
}