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 */} +
+

Site Statistics

+

+ Real-time analytics for this site. All data updates automatically. +

+
+ + {/* Stats cards grid */} +
+ {/* Active visitors card */} +
+
+ + Active Now +
+
{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; + } +}