mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
feat: add real-time stats page with live visitor tracking and page view analytics
This commit is contained in:
20
README.md
20
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 |
|
||||
|
||||
8
TASK.md
8
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
|
||||
|
||||
21
changelog.md
21
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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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**
|
||||
|
||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -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;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
15
convex/crons.ts
Normal file
15
convex/crons.ts
Normal file
@@ -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;
|
||||
|
||||
@@ -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"]),
|
||||
});
|
||||
|
||||
227
convex/stats.ts
Normal file
227
convex/stats.ts
Normal file
@@ -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<string, number> = {};
|
||||
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<string, number> = {};
|
||||
const uniqueSessions = new Set<string>();
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
36
files.md
36
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 |
|
||||
|
||||
@@ -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 (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/:slug" element={<Post />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
118
src/hooks/usePageTracking.ts
Normal file
118
src/hooks/usePageTracking.ts
Normal file
@@ -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<string | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(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]);
|
||||
}
|
||||
|
||||
@@ -109,7 +109,11 @@ export default function Home() {
|
||||
>
|
||||
project on GitHub
|
||||
</a>{" "}
|
||||
to fork and deploy your own.
|
||||
to fork and deploy your own. View{" "}
|
||||
<a href="/stats" className="home-text-link">
|
||||
real-time site stats
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
134
src/pages/Stats.tsx
Normal file
134
src/pages/Stats.tsx
Normal file
@@ -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 (
|
||||
<div className="stats-page">
|
||||
{/* Header with back button */}
|
||||
<nav className="stats-nav">
|
||||
<button onClick={() => navigate("/")} className="back-button">
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Page header */}
|
||||
<header className="stats-header">
|
||||
<h1 className="stats-title">Site Statistics</h1>
|
||||
<p className="stats-subtitle">
|
||||
Real-time analytics for this site. All data updates automatically.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Stats cards grid */}
|
||||
<section className="stats-grid">
|
||||
{/* Active visitors card */}
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-header">
|
||||
<Activity size={18} className="stat-card-icon" />
|
||||
<span className="stat-card-label">Active Now</span>
|
||||
</div>
|
||||
<div className="stat-card-value">{stats.activeVisitors}</div>
|
||||
<div className="stat-card-desc">Visitors on site</div>
|
||||
</div>
|
||||
|
||||
{/* Total page views card */}
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-header">
|
||||
<Eye size={18} className="stat-card-icon" />
|
||||
<span className="stat-card-label">Total Views</span>
|
||||
</div>
|
||||
<div className="stat-card-value">{stats.totalPageViews}</div>
|
||||
<div className="stat-card-desc">All-time page views</div>
|
||||
</div>
|
||||
|
||||
{/* Unique visitors card */}
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-header">
|
||||
<Users size={18} className="stat-card-icon" />
|
||||
<span className="stat-card-label">Unique Visitors</span>
|
||||
</div>
|
||||
<div className="stat-card-value">{stats.uniqueVisitors}</div>
|
||||
<div className="stat-card-desc">Unique sessions</div>
|
||||
</div>
|
||||
|
||||
{/* Published posts card */}
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-header">
|
||||
<BookOpen size={18} className="stat-card-icon" />
|
||||
<span className="stat-card-label">Blog Posts</span>
|
||||
</div>
|
||||
<div className="stat-card-value">{stats.publishedPosts}</div>
|
||||
<div className="stat-card-desc">Published posts</div>
|
||||
</div>
|
||||
|
||||
{/* Published pages card */}
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-header">
|
||||
<FileText size={18} className="stat-card-icon" />
|
||||
<span className="stat-card-label">Pages</span>
|
||||
</div>
|
||||
<div className="stat-card-value">{stats.publishedPages}</div>
|
||||
<div className="stat-card-desc">Static pages</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Active visitors by page */}
|
||||
{stats.activeByPath.length > 0 && (
|
||||
<section className="stats-section">
|
||||
<h2 className="stats-section-title">Currently Viewing</h2>
|
||||
<div className="stats-list">
|
||||
{stats.activeByPath.map((item) => (
|
||||
<div key={item.path} className="stats-list-item">
|
||||
<span className="stats-list-path">
|
||||
{item.path === "/" ? "Home" : item.path}
|
||||
</span>
|
||||
<span className="stats-list-count">
|
||||
{item.count} {item.count === 1 ? "visitor" : "visitors"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Page views by page */}
|
||||
{stats.pageStats.length > 0 && (
|
||||
<section className="stats-section">
|
||||
<h2 className="stats-section-title">Views by Page</h2>
|
||||
<div className="stats-list">
|
||||
{stats.pageStats.map((item) => (
|
||||
<div key={item.path} className="stats-list-item">
|
||||
<div className="stats-list-info">
|
||||
<span className="stats-list-title">{item.title}</span>
|
||||
<span className="stats-list-type">{item.pageType}</span>
|
||||
</div>
|
||||
<span className="stats-list-count">
|
||||
{item.views} {item.views === 1 ? "view" : "views"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user