Skip to content

Commit 5fec46d

Browse files
thunpisitclaude
andauthored
feat(v1.8): privacy-friendly analytics + search insights (closes #33) (#40)
Schema (Drizzle migration 0006) ------------------------------- - page_views: composite PK on (date, path), kind enum, optional refId, integer count. UPSERT path so we get one row per day per path with atomic increments. - search_log: anonymized {term, noResults, date} only. No IP, no UA, no fingerprint. Tracker ------- - $lib/server/analytics/index.ts: - trackView(db, { path, kind, refId }, consent) — gated on consent.analytics from the v1.7a cookie record. UPSERT bumps the counter atomically. Best-effort: errors never break a public page render. - logSearch(db, term, noResults) — always written when /blog?q= is used. Search is functional, not analytics-gated. - AnalyticsService: topPaths / topArticles / topSearchTerms / topNoResultTerms / sparkline / totalViews. All scoped to a trailing N days (default 30). Sparkline densifies missing days to 0 so the chart line stays continuous. Instrumentation --------------- - Public home, blog index, blog slug, page catch-all all call trackView. Blog index also calls logSearch when ?q= is set, with noResults flagged when articles.items.length === 0. Dashboard --------- - "Top articles (30 days)" tile: resolves refId → article title + link to the editor. Falls back to the raw path when refId is null (e.g. blog index). - "Search insights (30 days)" tile: split into Most-searched terms (clickable through to /blog?q=…) and Searches with no results (content-gap list). Both surface a non-scary "no data yet" empty state. Per-article sparkline --------------------- - Article edit page loads a 30-day series for /{locale}/blog/{slug} across every supported locale, merges by date, renders a tiny SVG sparkline + 30-day total. Hidden when total = 0 so a fresh article doesn't show a flatline. Cloudflare Web Analytics (optional) ----------------------------------- - /cms/settings gets a "Cloudflare Web Analytics token" field. - When set AND data.consent.analytics, the (www) layout injects the official beacon. Off by default. The first-party D1 counter runs regardless of this setting. Also includes a small svelte-check-only typing workaround on /cms/blocks for the cms_blocks_help paraglide message (build was already green; the cast just silences the per-message-types noise). i18n: 9 new keys (EN + TH). Closes #33 Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d653e42 commit 5fec46d

22 files changed

Lines changed: 2600 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ Khao Pad started as a CMS. Through v1.5 it became a complete content layer (writ
282282
| **v1.5** | Content versioning | ✅ Shipped | Per-article revision history, line diff, one-click restore, attribution |
283283
| **v1.6** | SEO foundations | ✅ Shipped | Per-page meta, sitemap, robots, JSON-LD, RSS/Atom, slug redirects, SEO scoring hint |
284284
| **v1.7** | Pages, navigation, IA | ✅ Shipped | Media folders, reusable blocks, cookie consent, static pages, navigation manager, seed:legal |
285-
| **v1.8** | Analytics & insight | 🚧 Pending | Privacy-friendly D1 page-views, top articles, search-term insights, per-article sparkline |
285+
| **v1.8** | Analytics & insight | ✅ Shipped | Privacy-friendly D1 page-views, top articles, search-term insights, per-article sparkline, optional CWA |
286286
| **v1.9** | Performance & trust | 🚧 Pending | Cloudflare Images responsive `srcset`, cache headers, custom 404/500, cookie consent, health check |
287287
| **v2.0** | Engagement & growth | 🚧 Pending | Forms, newsletter, comments, webhooks, public read-only API |
288288

docs/MILESTONES.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -208,15 +208,23 @@ A blog isn't a website. A website needs static pages (About, Contact, Privacy),
208208

209209
**i18n** — 18 keys in v1.7a + 23 keys in v1.7b (EN + TH).
210210

211-
### v1.8 — Analytics and insight (pending)
211+
### v1.8 — Analytics and insight
212212

213-
**Page-view counter** — Edge-side, privacy-respecting. New `analytics_events` table on D1 with a `Cloudflare Worker` middleware that increments a counter per `(path, day)` on every public page request, no IP/UA stored. Aggregates via the queue worker so the request path stays sub-millisecond. Optional integration with Cloudflare Web Analytics for the deeper view (referrers, devices, countries) — opt-in via a setting.
213+
Privacy-friendly editor analytics. Closes the "what's working?" gap left after v1.7. Every counter is gated on the v1.7a cookie consent — visitors who opt out write nothing. Aggregated by `(date, path)` so a busy site stays bounded. **No IP, no user agent, no fingerprint stored.**
214214

215-
**Editor analytics on the dashboard**Top 10 articles by views (last 30 days), top search terms (last 30 days, from `searchArticles` calls), draft-to-publish median time, articles updated this week. Sourced from `analytics_events` + `audit_log`. Adds a "Performance" card to the dashboard.
215+
**Schema**Drizzle migration 0006: `page_views` (composite PK on `(date, path)`, kind enum, optional `refId`, integer `count`) and `search_log` (anonymized `term` + `noResults` flag + `date`).
216216

217-
**Per-article analytics**On each article edit page, a sparkline of the last 30 days of views, pulled from `analytics_events`.
217+
**Tracker**`$lib/server/analytics/index.ts` exposes `trackView(db, opts, consent)` and `logSearch(db, term, noResults)`. View tracking does an UPSERT on the composite key — `INSERT … ON CONFLICT DO UPDATE SET count = count + 1` — so one row per day per path, atomic increments. Both functions are best-effort: a write failure never breaks the public page render. Tracking is instrumented on the home, blog index, blog slug, and the v1.7b page catch-all routes; the search log fires from `/blog?q=` whether or not analytics consent is given (search itself is functional, not analytics).
218218

219-
**Search-term insights** — Log every public-facing `searchArticles` query (anonymized, just the term + date) into a new `search_log` table. Dashboard tile: "Search terms with no results" — the high-signal list of content gaps to fill.
219+
**`AnalyticsService`** — Read surface used by the dashboard + article edit page. Methods: `topPaths` / `topArticles` / `topSearchTerms` / `topNoResultTerms` / `sparkline(path, days)` / `totalViews(path, days)`. All scoped to the last `days` (default 30) so the queries stay fast. The sparkline densifies the result so empty days return `count = 0` and the chart line stays continuous.
220+
221+
**Dashboard tiles** — Two new cards (admin / editor / author all see them — the data is non-sensitive aggregate). "Top articles (30 days)" resolves `refId → article` once at load time so the list shows real titles, not raw paths. "Search insights" splits into two stacks: most-searched terms (with click-through to `/blog?q=…`) and searches with no results (the content-gap list). Both surface a "no data yet" empty state that explains how to seed counters.
222+
223+
**Per-article sparkline** — Article edit page (`/cms/articles/[id]`) loads a 30-day series for the article's slug across every supported locale, merges them by date, and renders a tiny SVG sparkline above the form alongside a 30-day total. Runs lazily — the component returns nothing if there are no views yet, so a fresh article doesn't get a flat-line chart. All best-effort: the form still loads if analytics is unavailable.
224+
225+
**Cloudflare Web Analytics opt-in** — New `cfaToken` field in `/cms/settings`. When set AND the visitor consented to analytics, the (www) layout injects the official `https://static.cloudflareinsights.com/beacon.min.js` script with the operator's site token. Off by default; the first-party D1 counter runs regardless of this setting. No tracking code unless both conditions are met.
226+
227+
**i18n** — 9 new keys (EN + TH).
220228

221229
### v1.9 — Performance and trust (pending)
222230

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
CREATE TABLE `page_views` (
2+
`date` text NOT NULL,
3+
`path` text NOT NULL,
4+
`kind` text NOT NULL,
5+
`ref_id` text,
6+
`count` integer NOT NULL,
7+
PRIMARY KEY(`date`, `path`)
8+
);
9+
--> statement-breakpoint
10+
CREATE TABLE `search_log` (
11+
`id` text PRIMARY KEY NOT NULL,
12+
`term` text NOT NULL,
13+
`no_results` integer NOT NULL,
14+
`date` text NOT NULL,
15+
`created_at` text NOT NULL
16+
);

0 commit comments

Comments
 (0)