From 6c10829b1c2693b509d5433ecc6700ea265bf6c5 Mon Sep 17 00:00:00 2001
From: Wayne Sutton
Date: Sun, 14 Dec 2025 23:07:11 -0800
Subject: [PATCH] feat: add real-time stats page with live visitor tracking and
page view analytics
---
README.md | 20 +++
TASK.md | 8 +-
changelog.md | 21 ++++
content/blog/setup-guide.md | 20 +++
content/pages/docs.md | 20 ++-
convex/_generated/api.d.ts | 4 +
convex/crons.ts | 15 +++
convex/schema.ts | 20 +++
convex/stats.ts | 227 +++++++++++++++++++++++++++++++++++
files.md | 36 ++++--
src/App.tsx | 6 +
src/hooks/usePageTracking.ts | 118 ++++++++++++++++++
src/pages/Home.tsx | 6 +-
src/pages/Stats.tsx | 134 +++++++++++++++++++++
src/styles/global.css | 185 ++++++++++++++++++++++++++++
15 files changed, 821 insertions(+), 19 deletions(-)
create mode 100644 convex/crons.ts
create mode 100644 convex/stats.ts
create mode 100644 src/hooks/usePageTracking.ts
create mode 100644 src/pages/Stats.tsx
diff --git a/README.md b/README.md
index 906822e..1487317 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO
- Four theme options: Dark, Light, Tan (default), Cloud
- Real-time data with Convex
- Fully responsive design
+- Real-time analytics at `/stats`
### SEO and Discovery
@@ -264,10 +265,29 @@ markdown-site/
- lucide-react
- Netlify
+## Real-time Stats
+
+The `/stats` page shows real-time analytics powered by Convex:
+
+- **Active visitors**: Current visitors on the site with per-page breakdown
+- **Total page views**: All-time view count
+- **Unique visitors**: Based on anonymous session IDs
+- **Views by page**: List of all pages sorted by view count
+
+Stats update automatically via Convex subscriptions. No page refresh needed.
+
+How it works:
+
+- Page views are recorded as event records (not counters) to avoid write conflicts
+- Active sessions use heartbeat presence (30s interval, 2min timeout)
+- A cron job cleans up stale sessions every 5 minutes
+- No PII stored (only anonymous session UUIDs)
+
## API Endpoints
| Endpoint | Description |
| ------------------------------ | ------------------------------- |
+| `/stats` | Real-time site analytics |
| `/rss.xml` | RSS feed with post descriptions |
| `/rss-full.xml` | RSS feed with full post content |
| `/sitemap.xml` | Dynamic XML sitemap |
diff --git a/TASK.md b/TASK.md
index 17bdaba..e194cd0 100644
--- a/TASK.md
+++ b/TASK.md
@@ -2,7 +2,7 @@
## Current Status
-v1.1.0 ready for deployment. Build passes. TypeScript verified.
+v1.2.0 ready for deployment. Build passes. TypeScript verified.
## Completed
@@ -28,6 +28,11 @@ v1.1.0 ready for deployment. Build passes. TypeScript verified.
- [x] Mobile responsive design
- [x] Edge functions for dynamic Convex HTTP proxying
- [x] Vite dev server proxy for local development
+- [x] Real-time stats page at /stats
+- [x] Page view tracking with event records pattern
+- [x] Active session heartbeat system
+- [x] Cron job for stale session cleanup
+- [x] Stats link in homepage footer
## Deployment Steps
@@ -39,7 +44,6 @@ v1.1.0 ready for deployment. Build passes. TypeScript verified.
## Future Enhancements
- [ ] Search functionality
-- [ ] Post view counter display
- [ ] Related posts suggestions
- [ ] Newsletter signup
- [ ] Comments system
diff --git a/changelog.md b/changelog.md
index 5bd791e..20bca71 100644
--- a/changelog.md
+++ b/changelog.md
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
+## [1.2.0] - 2025-12-14
+
+### Added
+
+- Real-time stats page at `/stats` with live visitor tracking
+ - Active visitors count with per-page breakdown
+ - Total page views and unique visitors
+ - Views by page sorted by popularity
+- Page view tracking via event records pattern (no write conflicts)
+- Active session heartbeat system (30s interval, 2min timeout)
+- Cron job for stale session cleanup every 5 minutes
+- New Convex tables: `pageViews` and `activeSessions`
+- Stats link in homepage footer
+
+### Technical
+
+- Uses anonymous session UUIDs (no PII stored)
+- All stats update in real-time via Convex subscriptions
+- Mobile responsive stats grid (4 to 2 to 1 columns)
+- Theme support with CSS variables (dark, light, tan, cloud)
+
## [1.1.0] - 2025-12-14
### Added
diff --git a/content/blog/setup-guide.md b/content/blog/setup-guide.md
index 6a3dbc1..acc065c 100644
--- a/content/blog/setup-guide.md
+++ b/content/blog/setup-guide.md
@@ -463,12 +463,32 @@ Edit `index.html` to update:
Edit `public/llms.txt` and `public/robots.txt` with your site information.
+## Real-time Stats
+
+Your blog includes a real-time analytics page at `/stats`:
+
+- **Active visitors**: See who is currently on your site and which pages they are viewing
+- **Total page views**: All-time view count across the site
+- **Unique visitors**: Count based on anonymous session IDs
+- **Views by page**: Every page and post ranked by view count
+
+Stats update automatically without refreshing. Powered by Convex subscriptions.
+
+How it works:
+
+- Page views are recorded as event records (not counters) to prevent write conflicts
+- Active sessions use a heartbeat system (30 second interval)
+- Sessions expire after 2 minutes of inactivity
+- A cron job cleans up stale sessions every 5 minutes
+- No personal data is stored (only anonymous UUIDs)
+
## API Endpoints
Your blog includes these API endpoints for search engines and AI:
| Endpoint | Description |
| ------------------------------ | --------------------------- |
+| `/stats` | Real-time site analytics |
| `/rss.xml` | RSS feed with descriptions |
| `/rss-full.xml` | RSS feed with full content |
| `/sitemap.xml` | Dynamic XML sitemap |
diff --git a/content/pages/docs.md b/content/pages/docs.md
index f16cb8a..6a7b9f9 100644
--- a/content/pages/docs.md
+++ b/content/pages/docs.md
@@ -7,7 +7,7 @@ order: 0
Reference documentation for setting up, customizing, and deploying this markdown site.
-**How publishing works:** Write posts in markdown, run `npm run sync`, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so connected browsers update automatically.
+**How publishing works:** Write posts in markdown, run `npm run sync` for development or `npm run sync:prod` for production, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so connected browsers update automatically.
## Quick start
@@ -16,7 +16,8 @@ git clone https://github.com/waynesutton/markdown-site.git
cd markdown-site
npm install
npx convex dev
-npm run sync
+npm run sync # development
+npm run sync:prod # production
npm run dev
```
@@ -178,10 +179,22 @@ body {
| Default OG image | `public/images/og-default.svg` | 1200x630 |
| Post images | `public/images/` | Any |
+## Real-time stats
+
+The `/stats` page displays real-time analytics:
+
+- Active visitors (with per-page breakdown)
+- Total page views
+- Unique visitors
+- Views by page (sorted by count)
+
+All stats update automatically via Convex subscriptions.
+
## API endpoints
| Endpoint | Description |
| ------------------------------ | ----------------------- |
+| `/stats` | Real-time analytics |
| `/rss.xml` | RSS feed (descriptions) |
| `/rss-full.xml` | RSS feed (full content) |
| `/sitemap.xml` | XML sitemap |
@@ -250,7 +263,8 @@ export default defineSchema({
**Posts not appearing**
- Check `published: true` in frontmatter
-- Run `npm run sync`
+- Run `npm run sync` for development
+- Run `npm run sync:prod` for production
- Verify in Convex dashboard
**RSS/Sitemap errors**
diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts
index 8ad9135..9e72216 100644
--- a/convex/_generated/api.d.ts
+++ b/convex/_generated/api.d.ts
@@ -8,10 +8,12 @@
* @module
*/
+import type * as crons from "../crons.js";
import type * as http from "../http.js";
import type * as pages from "../pages.js";
import type * as posts from "../posts.js";
import type * as rss from "../rss.js";
+import type * as stats from "../stats.js";
import type {
ApiFromModules,
@@ -20,10 +22,12 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
+ crons: typeof crons;
http: typeof http;
pages: typeof pages;
posts: typeof posts;
rss: typeof rss;
+ stats: typeof stats;
}>;
/**
diff --git a/convex/crons.ts b/convex/crons.ts
new file mode 100644
index 0000000..84a6de9
--- /dev/null
+++ b/convex/crons.ts
@@ -0,0 +1,15 @@
+import { cronJobs } from "convex/server";
+import { internal } from "./_generated/api";
+
+const crons = cronJobs();
+
+// Clean up stale sessions every 5 minutes
+crons.interval(
+ "cleanup stale sessions",
+ { minutes: 5 },
+ internal.stats.cleanupStaleSessions,
+ {}
+);
+
+export default crons;
+
diff --git a/convex/schema.ts b/convex/schema.ts
index 0ff225e..dd061cc 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -50,4 +50,24 @@ export default defineSchema({
key: v.string(),
value: v.any(),
}).index("by_key", ["key"]),
+
+ // Page view events for analytics (event records pattern)
+ pageViews: defineTable({
+ path: v.string(),
+ pageType: v.string(), // "blog" | "page" | "home" | "stats"
+ sessionId: v.string(),
+ timestamp: v.number(),
+ })
+ .index("by_path", ["path"])
+ .index("by_timestamp", ["timestamp"])
+ .index("by_session_path", ["sessionId", "path"]),
+
+ // Active sessions for real-time visitor tracking
+ activeSessions: defineTable({
+ sessionId: v.string(),
+ currentPath: v.string(),
+ lastSeen: v.number(),
+ })
+ .index("by_sessionId", ["sessionId"])
+ .index("by_lastSeen", ["lastSeen"]),
});
diff --git a/convex/stats.ts b/convex/stats.ts
new file mode 100644
index 0000000..d4586d8
--- /dev/null
+++ b/convex/stats.ts
@@ -0,0 +1,227 @@
+import { query, mutation, internalMutation } from "./_generated/server";
+import { v } from "convex/values";
+
+// Deduplication window: 30 minutes in milliseconds
+const DEDUP_WINDOW_MS = 30 * 60 * 1000;
+
+// Session timeout: 2 minutes in milliseconds
+const SESSION_TIMEOUT_MS = 2 * 60 * 1000;
+
+/**
+ * Record a page view event.
+ * Idempotent: same session viewing same path within 30min = 1 view.
+ */
+export const recordPageView = mutation({
+ args: {
+ path: v.string(),
+ pageType: v.string(),
+ sessionId: v.string(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const dedupCutoff = now - DEDUP_WINDOW_MS;
+
+ // Check for recent view from same session on same path
+ const recentView = await ctx.db
+ .query("pageViews")
+ .withIndex("by_session_path", (q) =>
+ q.eq("sessionId", args.sessionId).eq("path", args.path)
+ )
+ .order("desc")
+ .first();
+
+ // Early return if already viewed within dedup window
+ if (recentView && recentView.timestamp > dedupCutoff) {
+ return null;
+ }
+
+ // Insert new view event
+ await ctx.db.insert("pageViews", {
+ path: args.path,
+ pageType: args.pageType,
+ sessionId: args.sessionId,
+ timestamp: now,
+ });
+
+ return null;
+ },
+});
+
+/**
+ * Update active session heartbeat.
+ * Creates or updates session with current path and timestamp.
+ */
+export const heartbeat = mutation({
+ args: {
+ sessionId: v.string(),
+ currentPath: v.string(),
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const now = Date.now();
+
+ // Find existing session by sessionId
+ const existingSession = await ctx.db
+ .query("activeSessions")
+ .withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
+ .first();
+
+ if (existingSession) {
+ // Update existing session
+ await ctx.db.patch(existingSession._id, {
+ currentPath: args.currentPath,
+ lastSeen: now,
+ });
+ } else {
+ // Create new session
+ await ctx.db.insert("activeSessions", {
+ sessionId: args.sessionId,
+ currentPath: args.currentPath,
+ lastSeen: now,
+ });
+ }
+
+ return null;
+ },
+});
+
+/**
+ * Get all stats for the stats page.
+ * Real-time subscription via useQuery.
+ */
+export const getStats = query({
+ args: {},
+ returns: v.object({
+ activeVisitors: v.number(),
+ activeByPath: v.array(
+ v.object({
+ path: v.string(),
+ count: v.number(),
+ })
+ ),
+ totalPageViews: v.number(),
+ uniqueVisitors: v.number(),
+ publishedPosts: v.number(),
+ publishedPages: v.number(),
+ pageStats: v.array(
+ v.object({
+ path: v.string(),
+ title: v.string(),
+ pageType: v.string(),
+ views: v.number(),
+ })
+ ),
+ }),
+ handler: async (ctx) => {
+ const now = Date.now();
+ const sessionCutoff = now - SESSION_TIMEOUT_MS;
+
+ // Get active sessions (heartbeat within last 2 minutes)
+ const activeSessions = await ctx.db
+ .query("activeSessions")
+ .withIndex("by_lastSeen", (q) => q.gt("lastSeen", sessionCutoff))
+ .collect();
+
+ // Count active visitors by path
+ const activeByPathMap: Record = {};
+ for (const session of activeSessions) {
+ activeByPathMap[session.currentPath] =
+ (activeByPathMap[session.currentPath] || 0) + 1;
+ }
+ const activeByPath = Object.entries(activeByPathMap)
+ .map(([path, count]) => ({ path, count }))
+ .sort((a, b) => b.count - a.count);
+
+ // Get all page views
+ const allViews = await ctx.db.query("pageViews").collect();
+
+ // Aggregate views by path and count unique sessions
+ const viewsByPath: Record = {};
+ const uniqueSessions = new Set();
+
+ for (const view of allViews) {
+ viewsByPath[view.path] = (viewsByPath[view.path] || 0) + 1;
+ uniqueSessions.add(view.sessionId);
+ }
+
+ // Get published posts and pages for titles
+ const posts = await ctx.db
+ .query("posts")
+ .withIndex("by_published", (q) => q.eq("published", true))
+ .collect();
+
+ const pages = await ctx.db
+ .query("pages")
+ .withIndex("by_published", (q) => q.eq("published", true))
+ .collect();
+
+ // Build page stats array with titles
+ const pageStats = Object.entries(viewsByPath)
+ .map(([path, views]) => {
+ // Match path to post or page
+ const slug = path.startsWith("/") ? path.slice(1) : path;
+ const post = posts.find((p) => p.slug === slug);
+ const page = pages.find((p) => p.slug === slug);
+
+ let title = path;
+ let pageType = "other";
+
+ if (path === "/" || path === "") {
+ title = "Home";
+ pageType = "home";
+ } else if (path === "/stats") {
+ title = "Stats";
+ pageType = "stats";
+ } else if (post) {
+ title = post.title;
+ pageType = "blog";
+ } else if (page) {
+ title = page.title;
+ pageType = "page";
+ }
+
+ return {
+ path,
+ title,
+ pageType,
+ views,
+ };
+ })
+ .sort((a, b) => b.views - a.views);
+
+ return {
+ activeVisitors: activeSessions.length,
+ activeByPath,
+ totalPageViews: allViews.length,
+ uniqueVisitors: uniqueSessions.size,
+ publishedPosts: posts.length,
+ publishedPages: pages.length,
+ pageStats,
+ };
+ },
+});
+
+/**
+ * Internal mutation to clean up stale sessions.
+ * Called by cron job every 5 minutes.
+ */
+export const cleanupStaleSessions = internalMutation({
+ args: {},
+ returns: v.number(),
+ handler: async (ctx) => {
+ const cutoff = Date.now() - SESSION_TIMEOUT_MS;
+
+ // Get all stale sessions
+ const staleSessions = await ctx.db
+ .query("activeSessions")
+ .withIndex("by_lastSeen", (q) => q.lt("lastSeen", cutoff))
+ .collect();
+
+ // Delete in parallel
+ await Promise.all(staleSessions.map((session) => ctx.db.delete(session._id)));
+
+ return staleSessions.length;
+ },
+});
+
diff --git a/files.md b/files.md
index b9b1136..ae7c7ce 100644
--- a/files.md
+++ b/files.md
@@ -28,10 +28,11 @@ A brief description of each file in the codebase.
### Pages (`src/pages/`)
-| File | Description |
-| ---------- | ------------------------------------------------------- |
-| `Home.tsx` | Landing page with intro, featured essays, and post list |
-| `Post.tsx` | Individual blog post view with JSON-LD injection |
+| File | Description |
+| ----------- | ------------------------------------------------------- |
+| `Home.tsx` | Landing page with intro, featured essays, and post list |
+| `Post.tsx` | Individual blog post view with JSON-LD injection |
+| `Stats.tsx` | Real-time analytics dashboard with visitor stats |
### Components (`src/components/`)
@@ -49,6 +50,12 @@ A brief description of each file in the codebase.
| ------------------ | ---------------------------------------------------- |
| `ThemeContext.tsx` | Theme state management with localStorage persistence |
+### Hooks (`src/hooks/`)
+
+| File | Description |
+| -------------------- | --------------------------------------------- |
+| `usePageTracking.ts` | Page view recording and active session heartbeat |
+
### Styles (`src/styles/`)
| File | Description |
@@ -57,20 +64,23 @@ A brief description of each file in the codebase.
## Convex Backend (`convex/`)
-| File | Description |
-| ------------------ | ------------------------------------------------- |
-| `schema.ts` | Database schema (posts, pages, viewCounts tables) |
-| `posts.ts` | Queries and mutations for blog posts, view counts |
-| `pages.ts` | Queries and mutations for static pages |
-| `http.ts` | HTTP endpoints: sitemap, API, Open Graph metadata |
-| `rss.ts` | RSS feed generation (standard and full content) |
-| `convex.config.ts` | Convex app configuration |
-| `tsconfig.json` | Convex TypeScript configuration |
+| File | Description |
+| ------------------ | ------------------------------------------------------------- |
+| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions) |
+| `posts.ts` | Queries and mutations for blog posts, view counts |
+| `pages.ts` | Queries and mutations for static pages |
+| `stats.ts` | Real-time stats queries, page view recording, session heartbeat |
+| `crons.ts` | Cron job for stale session cleanup |
+| `http.ts` | HTTP endpoints: sitemap, API, Open Graph metadata |
+| `rss.ts` | RSS feed generation (standard and full content) |
+| `convex.config.ts` | Convex app configuration |
+| `tsconfig.json` | Convex TypeScript configuration |
### HTTP Endpoints (defined in `http.ts`)
| Route | Description |
| --------------- | -------------------------------------- |
+| `/stats` | Real-time site analytics page |
| `/rss.xml` | RSS feed with descriptions |
| `/rss-full.xml` | RSS feed with full content for LLMs |
| `/sitemap.xml` | Dynamic XML sitemap for search engines |
diff --git a/src/App.tsx b/src/App.tsx
index 0c7854c..9ad522f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,13 +1,19 @@
import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Post from "./pages/Post";
+import Stats from "./pages/Stats";
import Layout from "./components/Layout";
+import { usePageTracking } from "./hooks/usePageTracking";
function App() {
+ // Track page views and active sessions
+ usePageTracking();
+
return (
} />
+ } />
} />
diff --git a/src/hooks/usePageTracking.ts b/src/hooks/usePageTracking.ts
new file mode 100644
index 0000000..974dec5
--- /dev/null
+++ b/src/hooks/usePageTracking.ts
@@ -0,0 +1,118 @@
+import { useEffect, useRef } from "react";
+import { useMutation } from "convex/react";
+import { useLocation } from "react-router-dom";
+import { api } from "../../convex/_generated/api";
+
+// Heartbeat interval: 30 seconds
+const HEARTBEAT_INTERVAL_MS = 30 * 1000;
+
+// Session ID key in localStorage
+const SESSION_ID_KEY = "markdown_blog_session_id";
+
+/**
+ * Generate a random session ID (UUID v4 format)
+ */
+function generateSessionId(): string {
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0;
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+}
+
+/**
+ * Get or create a persistent session ID
+ */
+function getSessionId(): string {
+ if (typeof window === "undefined") {
+ return generateSessionId();
+ }
+
+ let sessionId = localStorage.getItem(SESSION_ID_KEY);
+ if (!sessionId) {
+ sessionId = generateSessionId();
+ localStorage.setItem(SESSION_ID_KEY, sessionId);
+ }
+ return sessionId;
+}
+
+/**
+ * Determine page type from path
+ */
+function getPageType(path: string): string {
+ if (path === "/" || path === "") {
+ return "home";
+ }
+ if (path === "/stats") {
+ return "stats";
+ }
+ // Could be a blog post or static page
+ return "page";
+}
+
+/**
+ * Hook to track page views and maintain active session presence
+ */
+export function usePageTracking(): void {
+ const location = useLocation();
+ const recordPageView = useMutation(api.stats.recordPageView);
+ const heartbeat = useMutation(api.stats.heartbeat);
+
+ // Track if we've recorded view for current path
+ const lastRecordedPath = useRef(null);
+ const sessionIdRef = useRef(null);
+
+ // Initialize session ID
+ useEffect(() => {
+ sessionIdRef.current = getSessionId();
+ }, []);
+
+ // Record page view when path changes
+ useEffect(() => {
+ const path = location.pathname;
+ const sessionId = sessionIdRef.current;
+
+ if (!sessionId) return;
+
+ // Only record if path changed
+ if (lastRecordedPath.current !== path) {
+ lastRecordedPath.current = path;
+
+ recordPageView({
+ path,
+ pageType: getPageType(path),
+ sessionId,
+ }).catch(() => {
+ // Silently fail - analytics shouldn't break the app
+ });
+ }
+ }, [location.pathname, recordPageView]);
+
+ // Send heartbeat on interval and on path change
+ useEffect(() => {
+ const path = location.pathname;
+ const sessionId = sessionIdRef.current;
+
+ if (!sessionId) return;
+
+ // Send initial heartbeat
+ const sendHeartbeat = () => {
+ heartbeat({
+ sessionId,
+ currentPath: path,
+ }).catch(() => {
+ // Silently fail
+ });
+ };
+
+ sendHeartbeat();
+
+ // Set up interval for ongoing heartbeats
+ const intervalId = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
+
+ return () => {
+ clearInterval(intervalId);
+ };
+ }, [location.pathname, heartbeat]);
+}
+
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 4a80f52..a1bbb3a 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -109,7 +109,11 @@ export default function Home() {
>
project on GitHub
{" "}
- to fork and deploy your own.
+ to fork and deploy your own. View{" "}
+
+ real-time site stats
+
+ .
diff --git a/src/pages/Stats.tsx b/src/pages/Stats.tsx
new file mode 100644
index 0000000..e222608
--- /dev/null
+++ b/src/pages/Stats.tsx
@@ -0,0 +1,134 @@
+import { useQuery } from "convex/react";
+import { useNavigate } from "react-router-dom";
+import { api } from "../../convex/_generated/api";
+import {
+ ArrowLeft,
+ Users,
+ Eye,
+ FileText,
+ BookOpen,
+ Activity,
+} from "lucide-react";
+
+export default function Stats() {
+ const navigate = useNavigate();
+ const stats = useQuery(api.stats.getStats);
+
+ // Don't render until stats load
+ if (stats === undefined) {
+ return null;
+ }
+
+ return (
+
+ {/* Header with back button */}
+
+
+ {/* Page header */}
+
+
+ {/* Stats cards grid */}
+
+ {/* Active visitors card */}
+
+
+
{stats.activeVisitors}
+
Visitors on site
+
+
+ {/* Total page views card */}
+
+
+
+ Total Views
+
+
{stats.totalPageViews}
+
All-time page views
+
+
+ {/* Unique visitors card */}
+
+
+
+ Unique Visitors
+
+
{stats.uniqueVisitors}
+
Unique sessions
+
+
+ {/* Published posts card */}
+
+
+
+ Blog Posts
+
+
{stats.publishedPosts}
+
Published posts
+
+
+ {/* Published pages card */}
+
+
+
+ Pages
+
+
{stats.publishedPages}
+
Static pages
+
+
+
+ {/* Active visitors by page */}
+ {stats.activeByPath.length > 0 && (
+
+ Currently Viewing
+
+ {stats.activeByPath.map((item) => (
+
+
+ {item.path === "/" ? "Home" : item.path}
+
+
+ {item.count} {item.count === 1 ? "visitor" : "visitors"}
+
+
+ ))}
+
+
+ )}
+
+ {/* Page views by page */}
+ {stats.pageStats.length > 0 && (
+
+ Views by Page
+
+ {stats.pageStats.map((item) => (
+
+
+ {item.title}
+ {item.pageType}
+
+
+ {item.views} {item.views === 1 ? "view" : "views"}
+
+
+ ))}
+
+
+ )}
+
+ );
+}
+
diff --git a/src/styles/global.css b/src/styles/global.css
index 88c6f95..26c7c75 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -945,3 +945,188 @@ body {
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
+
+/* Stats page styles */
+.stats-page {
+ padding-top: 20px;
+}
+
+.stats-nav {
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ margin-bottom: 40px;
+}
+
+.stats-header {
+ margin-bottom: 40px;
+}
+
+.stats-title {
+ font-size: 32px;
+ font-weight: 400;
+ margin-bottom: 12px;
+ letter-spacing: -0.02em;
+ color: var(--text-primary);
+}
+
+.stats-subtitle {
+ font-size: 16px;
+ color: var(--text-secondary);
+ line-height: 1.7;
+}
+
+/* Stats cards grid */
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 16px;
+ margin-bottom: 48px;
+}
+
+.stat-card {
+ background-color: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 20px;
+ transition: background-color 0.2s ease;
+}
+
+.stat-card:hover {
+ background-color: var(--bg-hover);
+}
+
+.stat-card-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.stat-card-icon {
+ color: var(--text-muted);
+}
+
+.stat-card-label {
+ font-size: 14px;
+ color: var(--text-muted);
+}
+
+.stat-card-value {
+ font-size: 32px;
+ font-weight: 400;
+ color: var(--text-primary);
+ margin-bottom: 4px;
+ letter-spacing: -0.02em;
+}
+
+.stat-card-desc {
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+/* Stats sections */
+.stats-section {
+ margin-bottom: 40px;
+}
+
+.stats-section-title {
+ font-size: 18px;
+ font-weight: 400;
+ color: var(--text-primary);
+ margin-bottom: 16px;
+ letter-spacing: -0.01em;
+}
+
+/* Stats list */
+.stats-list {
+ background-color: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.stats-list-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 14px 16px;
+ transition: background-color 0.15s ease;
+}
+
+.stats-list-item:hover {
+ background-color: var(--bg-hover);
+}
+
+.stats-list-item:not(:last-child) {
+ border-bottom: 1px solid var(--border-color);
+}
+
+.stats-list-info {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.stats-list-path,
+.stats-list-title {
+ font-size: 15px;
+ color: var(--text-primary);
+}
+
+.stats-list-type {
+ font-size: 12px;
+ color: var(--text-muted);
+ background-color: var(--bg-hover);
+ padding: 2px 8px;
+ border-radius: 10px;
+}
+
+.stats-list-count {
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+/* Stats responsive styles */
+@media (max-width: 900px) {
+ .stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 768px) {
+ .stats-title {
+ font-size: 28px;
+ }
+
+ .stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: 12px;
+ }
+
+ .stat-card {
+ padding: 16px;
+ }
+
+ .stat-card-value {
+ font-size: 28px;
+ }
+
+ .stats-list-item {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .stats-list-info {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ }
+}
+
+@media (max-width: 480px) {
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+}