docs: add visitor map configuration to README and update docs.md

- Added visitor map section to README.md after GitHub Contributions Graph
- Updated visitor map description in content/pages/docs.md with privacy details
- Documents configuration options in src/config/siteConfig.ts
- Explains Netlify geo detection and theme-aware colors
This commit is contained in:
Wayne Sutton
2025-12-21 15:58:43 -08:00
parent 1e67e492c8
commit 4a912fd345
32 changed files with 1129 additions and 37 deletions

View File

@@ -132,6 +132,12 @@ export const siteConfig: SiteConfig = {
title: "GitHub Activity", title: "GitHub Activity",
}, },
// Visitor map (stats page)
visitorMap: {
enabled: true,
title: "Live Visitors",
},
// Blog page // Blog page
blogPage: { blogPage: {
enabled: true, enabled: true,

View File

@@ -366,6 +366,26 @@ The graph displays with theme-aware colors that match each site theme:
Uses the public `github-contributions-api.jogruber.de` API (no GitHub token required). Uses the public `github-contributions-api.jogruber.de` API (no GitHub token required).
## Visitor Map
Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored.
Configure in `src/config/siteConfig.ts`:
```typescript
visitorMap: {
enabled: true, // Set to false to hide the visitor map
title: "Live Visitors", // Optional title above the map
},
```
| Option | Description |
| --------- | ------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `title` | Text above map (set to `undefined` to hide) |
The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge.
### Favicon ### Favicon
Replace `public/favicon.svg` with your own icon. The default is a rounded square with the letter "m". Edit the SVG to change the letter or style. Replace `public/favicon.svg` with your own icon. The default is a rounded square with the letter "m". Edit the SVG to change the letter or style.

12
TASK.md
View File

@@ -4,10 +4,20 @@
## Current Status ## Current Status
v1.19.1 deployed. Author display (authorName/authorImage) and GitHub Stars on Stats page. v1.20.2 deployed. Write conflict prevention for heartbeat mutations.
## Completed ## Completed
- [x] Write conflict prevention: increased dedup windows, added heartbeat jitter
- [x] Visitor map styling: removed box-shadow, increased land dot contrast and opacity
- [x] Real-time visitor map on stats page showing live visitor locations
- [x] Netlify edge function for geo detection (geo.ts)
- [x] VisitorMap component with dotted world map and pulsing dots
- [x] Theme-aware colors for all four themes (dark, light, tan, cloud)
- [x] visitorMap config option in siteConfig.ts to enable/disable
- [x] Privacy friendly: no IP addresses stored, only city/country/coordinates
- [x] Documentation updated: setup-guide, docs, FORK_CONFIG, fork-config.json.example
- [x] Author display for posts and pages with authorName and authorImage frontmatter fields - [x] Author display for posts and pages with authorName and authorImage frontmatter fields
- [x] Round avatar image displayed next to date and read time on post/page views - [x] Round avatar image displayed next to date and read time on post/page views
- [x] Write page updated with new frontmatter field reference - [x] Write page updated with new frontmatter field reference

View File

@@ -4,6 +4,64 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.20.2] - 2025-12-21
### Fixed
- Write conflict prevention for heartbeat mutation
- Increased backend dedup window from 10s to 20s
- Increased frontend debounce from 10s to 20s to match backend
- Added random jitter (±5s) to heartbeat intervals to prevent synchronized calls across tabs
- Simplified early return to skip ANY update within dedup window (not just same path)
- Prevents "Documents read from or written to the activeSessions table changed" errors
## [1.20.1] - 2025-12-21
### Changed
- Visitor map styling improvements
- Removed box-shadow from map wrapper for cleaner flat design
- Increased land dot contrast for better globe visibility on all themes
- Increased land dot opacity from 0.6 to 0.85
- Darker/more visible land colors for light, tan, and cloud themes
- Lighter land color for dark theme to stand out on dark background
## [1.20.0] - 2025-12-21
### Added
- Real-time visitor map on stats page
- Displays live visitor locations on a dotted world map
- Uses Netlify's built-in geo detection via edge function (no third-party API needed)
- Privacy friendly: stores city, country, and coordinates only (no IP addresses)
- Theme-aware colors for all four themes (dark, light, tan, cloud)
- Animated pulsing dots for active visitors
- Visitor count badge showing online visitors
- Configurable via `siteConfig.visitorMap`
- New Netlify edge function: `netlify/edge-functions/geo.ts`
- Returns user geo data from Netlify's automatic geo headers
- Endpoint: `/api/geo`
- New React component: `src/components/VisitorMap.tsx`
- SVG-based world map with simplified continent outlines
- Lightweight (no external map library needed)
- Responsive design scales on mobile
### Changed
- Updated `convex/schema.ts`: Added optional location fields to `activeSessions` table (city, country, latitude, longitude)
- Updated `convex/stats.ts`: Heartbeat mutation accepts geo args, getStats returns visitor locations
- Updated `src/hooks/usePageTracking.ts`: Fetches geo data once on mount, passes to heartbeat
- Updated `src/pages/Stats.tsx`: Displays VisitorMap above "Currently Viewing" section
- Updated `src/config/siteConfig.ts`: Added `VisitorMapConfig` interface and `visitorMap` config option
### Documentation
- Updated setup-guide.md with Visitor Map section
- Updated docs.md with Visitor Map configuration
- Updated FORK_CONFIG.md with visitorMap config
- Updated fork-config.json.example with visitorMap option
- Updated fork-configuration-guide.md with visitorMap example
## [1.19.1] - 2025-12-21 ## [1.19.1] - 2025-12-21
### Added ### Added

View File

@@ -128,6 +128,10 @@ The JSON config file supports additional options:
"linkToProfile": true, "linkToProfile": true,
"title": "GitHub Activity" "title": "GitHub Activity"
}, },
"visitorMap": {
"enabled": true,
"title": "Live Visitors"
},
"blogPage": { "blogPage": {
"enabled": true, "enabled": true,
"showInNav": true, "showInNav": true,

View File

@@ -6,7 +6,7 @@ slug: "new-features-search-featured-logos"
published: true published: true
tags: ["features", "search", "convex", "updates"] tags: ["features", "search", "convex", "updates"]
readTime: "4 min read" readTime: "4 min read"
featured: true featured: false
featuredOrder: 5 featuredOrder: 5
authorName: "Markdown" authorName: "Markdown"
authorImage: "/images/authors/markdown.png" authorImage: "/images/authors/markdown.png"

View File

@@ -7,7 +7,7 @@ published: true
tags: ["convex", "netlify", "tutorial", "deployment"] tags: ["convex", "netlify", "tutorial", "deployment"]
readTime: "8 min read" readTime: "8 min read"
featured: true featured: true
featuredOrder: 3 featuredOrder: 6
image: "/images/setupguide.png" image: "/images/setupguide.png"
authorName: "Markdown" authorName: "Markdown"
authorImage: "/images/authors/markdown.png" authorImage: "/images/authors/markdown.png"
@@ -58,6 +58,7 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Update Site Configuration](#update-site-configuration) - [Update Site Configuration](#update-site-configuration)
- [Featured Section](#featured-section) - [Featured Section](#featured-section)
- [GitHub Contributions Graph](#github-contributions-graph) - [GitHub Contributions Graph](#github-contributions-graph)
- [Visitor Map](#visitor-map)
- [Logo Gallery](#logo-gallery) - [Logo Gallery](#logo-gallery)
- [Blog page](#blog-page) - [Blog page](#blog-page)
- [Scroll-to-top button](#scroll-to-top-button) - [Scroll-to-top button](#scroll-to-top-button)
@@ -699,6 +700,26 @@ gitHubContributions: {
The graph displays with theme-aware colors that match each site theme (dark, light, tan, cloud). Uses the public `github-contributions-api.jogruber.de` API (no GitHub token required). The graph displays with theme-aware colors that match each site theme (dark, light, tan, cloud). Uses the public `github-contributions-api.jogruber.de` API (no GitHub token required).
### Visitor Map
Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored.
Configure in `siteConfig`:
```typescript
visitorMap: {
enabled: true, // Set to false to hide the visitor map
title: "Live Visitors", // Optional title above the map
},
```
| Option | Description |
| --------- | ------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `title` | Text above map (set to `undefined` to hide) |
The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge.
### Logo Gallery ### Logo Gallery
The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Customize or disable it in siteConfig: The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Customize or disable it in siteConfig:

View File

@@ -5,7 +5,7 @@ date: "2025-12-14"
slug: "using-images-in-posts" slug: "using-images-in-posts"
published: true published: true
featured: true featured: true
featuredOrder: 3 featuredOrder: 4
tags: ["images", "tutorial", "markdown", "open-graph"] tags: ["images", "tutorial", "markdown", "open-graph"]
readTime: "4 min read" readTime: "4 min read"
authorName: "Markdown" authorName: "Markdown"

View File

@@ -0,0 +1,133 @@
---
title: "Visitor tracking and stats improvements"
description: "Real-time visitor map, write conflict prevention, GitHub Stars integration, and better AI prompts. Updates from v1.18.1 to v1.20.2."
date: "2025-12-21"
slug: "visitor-tracking-and-stats-improvements"
published: true
tags: ["features", "stats", "convex", "updates", "analytics"]
readTime: "5 min read"
featured: true
featuredOrder: 1
authorName: "Markdown"
authorImage: "/images/authors/markdown.png"
image: "/images/122.png"
excerpt: "Real-time visitor map shows where your readers are. Write conflict prevention keeps stats accurate. GitHub Stars and improved AI prompts round out the updates."
---
## Real-time visitor map
The stats page now shows a live world map with visitor locations. Each active visitor appears as a pulsing dot on the map.
The map uses Netlify's built-in geo detection. No third-party API needed. No IP addresses stored. Just city, country, and coordinates.
Configure it in siteConfig:
```typescript
visitorMap: {
enabled: true,
title: "Live Visitors",
},
```
The map works with all four themes. Dark, light, tan, cloud. Land dots use theme-aware colors. Visitor dots pulse to show activity.
Implementation details:
- New edge function at `netlify/edge-functions/geo.ts` reads Netlify geo headers
- React component `VisitorMap.tsx` renders an SVG world map
- No external map library required
- Responsive design scales on mobile
The map updates in real time as visitors navigate your site. Each heartbeat includes location data. The map aggregates active sessions and displays them on the globe.
## Write conflict prevention
Convex write conflicts happen when multiple mutations update the same document concurrently. The heartbeat mutation was hitting this with rapid page navigation.
Version 1.20.2 fixes this with three changes:
1. Increased dedup window from 10 seconds to 20 seconds
2. Added random jitter (±5 seconds) to heartbeat intervals
3. Simplified early return to skip any update within the dedup window
The jitter prevents synchronized calls across browser tabs. If you have multiple tabs open, they no longer fire heartbeats at the same time.
Backend and frontend now use matching 20-second windows. The backend checks if a session was updated recently. If yes, it returns early without patching. This makes the mutation idempotent and prevents conflicts.
## GitHub Stars integration
The stats page now displays your repository's star count. Fetches from GitHub's public API. No token required.
The card shows the live count and updates on page load. Uses the Phosphor GithubLogo icon for consistency.
Stats page layout changed to accommodate six cards:
- Desktop: single row of six cards
- Tablet: 3x2 grid
- Mobile: 2x3 grid
- Small mobile: stacked
The GitHub Stars card sits alongside total page views, unique visitors, active visitors, and views by page.
## Author display
Posts and pages now support author attribution. Add these fields to frontmatter:
```yaml
authorName: "Your Name"
authorImage: "/images/authors/photo.png"
```
The author info appears on individual post and page views. Round avatar image next to the date and read time. Not shown on blog list views.
Place author images in `public/images/authors/`. Square images work best since they display as circles.
## Improved AI prompts
The CopyPageDropdown now sends better instructions to ChatGPT, Claude, and Perplexity.
Previous prompts asked AI to summarize without verifying the content loaded. New prompts instruct AI to:
1. Attempt to load the raw markdown URL
2. If successful, provide a concise summary and ask how to help
3. If not accessible, state the page could not be loaded without guessing
This prevents AI from hallucinating content when URLs fail to load. More reliable results for users sharing pages.
## Raw markdown URLs
Version 1.18.1 changed AI services to use raw markdown file URLs instead of page URLs. Format: `/raw/{slug}.md`.
AI services can fetch and parse clean markdown directly. No HTML parsing required. Includes metadata headers for structured parsing.
The raw markdown files are generated during `npm run sync`. Each published post and page gets a corresponding static `.md` file in `public/raw/`.
## Technical notes
All updates maintain backward compatibility. Existing sites continue working without changes.
The visitor map requires Netlify edge functions. The geo endpoint reads automatic headers from Netlify's CDN. No configuration needed beyond enabling the feature.
Write conflict prevention uses Convex best practices:
- Idempotent mutations with early returns
- Indexed queries for efficient lookups
- Event records pattern for high-frequency updates
- Debouncing on the frontend
The stats page aggregates data from multiple sources:
- Active sessions from heartbeat mutations
- Page views from event records
- GitHub Stars from public API
- Visitor locations from geo edge function
All data updates in real time via Convex subscriptions.
## What's next
These updates focus on making stats more useful and reliable. The visitor map provides visual feedback. Write conflict prevention ensures accuracy. GitHub Stars adds social proof.
Future updates will continue improving the analytics experience. More granular stats. Better visualization. More configuration options.
Check the changelog for the full list of changes from v1.18.1 to v1.20.2.

View File

@@ -7,6 +7,62 @@ order: 5
All notable changes to this project. All notable changes to this project.
## v1.20.2
Released December 21, 2025
**Write conflict prevention for heartbeat mutation**
- Increased backend dedup window from 10s to 20s
- Increased frontend debounce from 10s to 20s to match backend
- Added random jitter (±5s) to heartbeat intervals to prevent synchronized calls across tabs
- Simplified early return to skip ANY update within dedup window
- Prevents "Documents read from or written to the activeSessions table changed" errors
## v1.20.1
Released December 21, 2025
**Visitor map styling improvements**
- Removed box-shadow from map wrapper for cleaner flat design
- Increased land dot contrast for better globe visibility on all themes
- Increased land dot opacity from 0.6 to 0.85
- Darker/more visible land colors for light, tan, and cloud themes
- Lighter land color for dark theme to stand out on dark background
## v1.20.0
Released December 21, 2025
**Real-time visitor map on stats page**
- Displays live visitor locations on a dotted world map
- Uses Netlify's built-in geo detection via edge function (no third-party API needed)
- Privacy friendly: stores city, country, and coordinates only (no IP addresses)
- Theme-aware colors for all four themes (dark, light, tan, cloud)
- Animated pulsing dots for active visitors
- Configurable via `siteConfig.visitorMap`
New files:
- `netlify/edge-functions/geo.ts`: Edge function returning geo data from Netlify headers
- `src/components/VisitorMap.tsx`: SVG world map component with visitor dots
Configuration:
```typescript
// src/config/siteConfig.ts
visitorMap: {
enabled: true, // Set to false to hide
title: "Live Visitors", // Optional title above the map
},
```
Updated files: `convex/schema.ts`, `convex/stats.ts`, `src/hooks/usePageTracking.ts`, `src/pages/Stats.tsx`, `src/config/siteConfig.ts`, `src/styles/global.css`
Documentation updated: setup-guide.md, docs.md, FORK_CONFIG.md, fork-config.json.example, fork-configuration-guide.md
## v1.19.2 ## v1.19.2
Released December 21, 2025 Released December 21, 2025

View File

@@ -360,6 +360,24 @@ gitHubContributions: {
Theme-aware colors match each site theme. Uses public API (no GitHub token required). Theme-aware colors match each site theme. Uses public API (no GitHub token required).
### Visitor map
Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored.
```typescript
visitorMap: {
enabled: true, // Set to false to hide
title: "Live Visitors", // Optional title above the map
},
```
| Option | Description |
| --------- | ------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `title` | Text above map (`undefined` to hide) |
The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge.
### Logo gallery ### Logo gallery
The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL. The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL.

View File

@@ -88,6 +88,11 @@ export default defineSchema({
sessionId: v.string(), sessionId: v.string(),
currentPath: v.string(), currentPath: v.string(),
lastSeen: v.number(), lastSeen: v.number(),
// Location data (optional, from Netlify geo headers)
city: v.optional(v.string()),
country: v.optional(v.string()),
latitude: v.optional(v.number()),
longitude: v.optional(v.number()),
}) })
.index("by_sessionId", ["sessionId"]) .index("by_sessionId", ["sessionId"])
.index("by_lastSeen", ["lastSeen"]), .index("by_lastSeen", ["lastSeen"]),

View File

@@ -10,8 +10,8 @@ const DEDUP_WINDOW_MS = 30 * 60 * 1000;
// Session timeout: 2 minutes in milliseconds // Session timeout: 2 minutes in milliseconds
const SESSION_TIMEOUT_MS = 2 * 60 * 1000; const SESSION_TIMEOUT_MS = 2 * 60 * 1000;
// Heartbeat dedup window: 10 seconds (prevents write conflicts from rapid calls) // Heartbeat dedup window: 20 seconds (prevents write conflicts from rapid calls or multiple tabs)
const HEARTBEAT_DEDUP_MS = 10 * 1000; const HEARTBEAT_DEDUP_MS = 20 * 1000;
/** /**
* Aggregate for page views by path. * Aggregate for page views by path.
@@ -73,7 +73,7 @@ export const recordPageView = mutation({
const recentView = await ctx.db const recentView = await ctx.db
.query("pageViews") .query("pageViews")
.withIndex("by_session_path", (q) => .withIndex("by_session_path", (q) =>
q.eq("sessionId", args.sessionId).eq("path", args.path) q.eq("sessionId", args.sessionId).eq("path", args.path),
) )
.order("desc") .order("desc")
.first(); .first();
@@ -116,12 +116,23 @@ export const recordPageView = mutation({
/** /**
* Update active session heartbeat. * Update active session heartbeat.
* Creates or updates session with current path and timestamp. * Creates or updates session with current path and timestamp.
* Accepts optional geo location data from Netlify edge function.
* Idempotent: skips update if recently updated with same path (prevents write conflicts). * Idempotent: skips update if recently updated with same path (prevents write conflicts).
*
* Write conflict prevention:
* - Uses 20-second dedup window to skip redundant updates
* - Frontend uses matching debounce with jitter to prevent synchronized calls
* - Early return pattern minimizes conflict window
*/ */
export const heartbeat = mutation({ export const heartbeat = mutation({
args: { args: {
sessionId: v.string(), sessionId: v.string(),
currentPath: v.string(), currentPath: v.string(),
// Optional geo data from Netlify geo headers
city: v.optional(v.string()),
country: v.optional(v.string()),
latitude: v.optional(v.number()),
longitude: v.optional(v.number()),
}, },
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -134,27 +145,33 @@ export const heartbeat = mutation({
.first(); .first();
if (existingSession) { if (existingSession) {
// Early return if same path and recently updated (idempotent - prevents write conflicts) // Early return if recently updated (idempotent - prevents write conflicts)
if ( // Even if path changed, skip update if within dedup window to reduce conflicts
existingSession.currentPath === args.currentPath && if (now - existingSession.lastSeen < HEARTBEAT_DEDUP_MS) {
now - existingSession.lastSeen < HEARTBEAT_DEDUP_MS
) {
return null; return null;
} }
// Patch directly with new data // Patch directly with new data including location if provided
await ctx.db.patch(existingSession._id, { await ctx.db.patch(existingSession._id, {
currentPath: args.currentPath, currentPath: args.currentPath,
lastSeen: now, lastSeen: now,
...(args.city !== undefined && { city: args.city }),
...(args.country !== undefined && { country: args.country }),
...(args.latitude !== undefined && { latitude: args.latitude }),
...(args.longitude !== undefined && { longitude: args.longitude }),
}); });
return null; return null;
} }
// Create new session only if none exists // Create new session only if none exists (with location data if provided)
await ctx.db.insert("activeSessions", { await ctx.db.insert("activeSessions", {
sessionId: args.sessionId, sessionId: args.sessionId,
currentPath: args.currentPath, currentPath: args.currentPath,
lastSeen: now, lastSeen: now,
...(args.city !== undefined && { city: args.city }),
...(args.country !== undefined && { country: args.country }),
...(args.latitude !== undefined && { latitude: args.latitude }),
...(args.longitude !== undefined && { longitude: args.longitude }),
}); });
return null; return null;
@@ -165,6 +182,7 @@ export const heartbeat = mutation({
* Get all stats for the stats page. * Get all stats for the stats page.
* Real-time subscription via useQuery. * Real-time subscription via useQuery.
* Uses aggregate components for O(log n) counts instead of O(n) table scans. * Uses aggregate components for O(log n) counts instead of O(n) table scans.
* Returns visitor locations for the world map display.
*/ */
export const getStats = query({ export const getStats = query({
args: {}, args: {},
@@ -174,7 +192,7 @@ export const getStats = query({
v.object({ v.object({
path: v.string(), path: v.string(),
count: v.number(), count: v.number(),
}) }),
), ),
totalPageViews: v.number(), totalPageViews: v.number(),
uniqueVisitors: v.number(), uniqueVisitors: v.number(),
@@ -187,7 +205,16 @@ export const getStats = query({
title: v.string(), title: v.string(),
pageType: v.string(), pageType: v.string(),
views: v.number(), views: v.number(),
}) }),
),
// Visitor locations for world map display
visitorLocations: v.array(
v.object({
latitude: v.number(),
longitude: v.number(),
city: v.optional(v.string()),
country: v.optional(v.string()),
}),
), ),
}), }),
handler: async (ctx) => { handler: async (ctx) => {
@@ -214,11 +241,11 @@ export const getStats = query({
// We use direct counting until aggregates are fully backfilled // We use direct counting until aggregates are fully backfilled
const allPageViews = await ctx.db.query("pageViews").collect(); const allPageViews = await ctx.db.query("pageViews").collect();
const totalPageViewsCount = allPageViews.length; const totalPageViewsCount = allPageViews.length;
// Count unique sessions from the views // Count unique sessions from the views
const uniqueSessions = new Set(allPageViews.map((v) => v.sessionId)); const uniqueSessions = new Set(allPageViews.map((v) => v.sessionId));
const uniqueVisitorsCount = uniqueSessions.size; const uniqueVisitorsCount = uniqueSessions.size;
// Count views per path from the raw data // Count views per path from the raw data
const pathCountsFromDb: Record<string, number> = {}; const pathCountsFromDb: Record<string, number> = {};
for (const view of allPageViews) { for (const view of allPageViews) {
@@ -248,7 +275,7 @@ export const getStats = query({
// Build page stats using direct counts (always accurate) // Build page stats using direct counts (always accurate)
const pageStatsPromises = allPaths.map(async (path) => { const pageStatsPromises = allPaths.map(async (path) => {
const views = pathCountsFromDb[path] || 0; const views = pathCountsFromDb[path] || 0;
// Match path to post or page for title // Match path to post or page for title
const slug = path.startsWith("/") ? path.slice(1) : path; const slug = path.startsWith("/") ? path.slice(1) : path;
const post = posts.find((p) => p.slug === slug); const post = posts.find((p) => p.slug === slug);
@@ -280,9 +307,25 @@ export const getStats = query({
}); });
const pageStats = (await Promise.all(pageStatsPromises)).sort( const pageStats = (await Promise.all(pageStatsPromises)).sort(
(a, b) => b.views - a.views (a, b) => b.views - a.views,
); );
// Extract visitor locations from active sessions (only those with coordinates)
const visitorLocations = activeSessions
.filter(
(s): s is typeof s & { latitude: number; longitude: number } =>
s.latitude !== undefined &&
s.longitude !== undefined &&
s.latitude !== null &&
s.longitude !== null,
)
.map((s) => ({
latitude: s.latitude,
longitude: s.longitude,
city: s.city,
country: s.country,
}));
return { return {
activeVisitors: activeSessions.length, activeVisitors: activeSessions.length,
activeByPath, activeByPath,
@@ -292,6 +335,7 @@ export const getStats = query({
publishedPages: pages.length, publishedPages: pages.length,
trackingSince, trackingSince,
pageStats, pageStats,
visitorLocations,
}; };
}, },
}); });
@@ -313,7 +357,9 @@ export const cleanupStaleSessions = internalMutation({
.collect(); .collect();
// Delete in parallel // Delete in parallel
await Promise.all(staleSessions.map((session) => ctx.db.delete(session._id))); await Promise.all(
staleSessions.map((session) => ctx.db.delete(session._id)),
);
return staleSessions.length; return staleSessions.length;
}, },
@@ -376,12 +422,13 @@ export const backfillAggregatesChunk = internalMutation({
await ctx.scheduler.runAfter( await ctx.scheduler.runAfter(
0, 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(await import("./_generated/api")).internal.stats.backfillAggregatesChunk as any, (await import("./_generated/api")).internal.stats
.backfillAggregatesChunk as any,
{ {
cursor: result.continueCursor, cursor: result.continueCursor,
totalProcessed: newTotalProcessed, totalProcessed: newTotalProcessed,
seenSessionIds: sessionArray, seenSessionIds: sessionArray,
} },
); );
return { return {
@@ -423,15 +470,15 @@ export const backfillAggregates = internalMutation({
await ctx.scheduler.runAfter( await ctx.scheduler.runAfter(
0, 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(await import("./_generated/api")).internal.stats.backfillAggregatesChunk as any, (await import("./_generated/api")).internal.stats
.backfillAggregatesChunk as any,
{ {
cursor: null, cursor: null,
totalProcessed: 0, totalProcessed: 0,
seenSessionIds: [], seenSessionIds: [],
} },
); );
return { message: "Backfill started. Check logs for progress." }; return { message: "Backfill started. Check logs for progress." };
}, },
}); });

View File

@@ -60,6 +60,7 @@ A brief description of each file in the codebase.
| `MobileMenu.tsx` | Slide-out drawer menu for mobile navigation with hamburger button | | `MobileMenu.tsx` | Slide-out drawer menu for mobile navigation with hamburger button |
| `ScrollToTop.tsx` | Configurable scroll-to-top button with Phosphor ArrowUp icon | | `ScrollToTop.tsx` | Configurable scroll-to-top button with Phosphor ArrowUp icon |
| `GitHubContributions.tsx` | GitHub activity graph with theme-aware colors and year navigation | | `GitHubContributions.tsx` | GitHub activity graph with theme-aware colors and year navigation |
| `VisitorMap.tsx` | Real-time visitor location map with dotted world display and theme-aware colors |
### Context (`src/context/`) ### Context (`src/context/`)
@@ -177,6 +178,7 @@ Frontmatter is the YAML metadata at the top of each markdown file. Here is how i
| `rss.ts` | Proxies `/rss.xml` and `/rss-full.xml` to Convex HTTP | | `rss.ts` | Proxies `/rss.xml` and `/rss-full.xml` to Convex HTTP |
| `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP | | `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP |
| `api.ts` | Proxies `/api/posts`, `/api/post`, `/api/export` to Convex | | `api.ts` | Proxies `/api/posts`, `/api/post`, `/api/export` to Convex |
| `geo.ts` | Returns user geo location from Netlify's automatic geo headers for visitor map |
## Public Assets (`public/`) ## Public Assets (`public/`)

View File

@@ -26,6 +26,10 @@
"linkToProfile": true, "linkToProfile": true,
"title": "GitHub Activity" "title": "GitHub Activity"
}, },
"visitorMap": {
"enabled": true,
"title": "Live Visitors"
},
"blogPage": { "blogPage": {
"enabled": true, "enabled": true,
"showInNav": true, "showInNav": true,

View File

@@ -39,6 +39,11 @@
path = "/api/export" path = "/api/export"
function = "api" function = "api"
# Geo location API for visitor map
[[edge_functions]]
path = "/api/geo"
function = "geo"
# Open Graph bot detection (catches all other routes) # Open Graph bot detection (catches all other routes)
[[edge_functions]] [[edge_functions]]
path = "/*" path = "/*"

View File

@@ -0,0 +1,32 @@
import type { Context } from "@netlify/edge-functions";
// Returns the user's geo location from Netlify's automatic geo headers
// Privacy friendly: only returns city/country/coordinates, no IP address stored
export default async function handler(
_request: Request,
context: Context,
): Promise<Response> {
// Netlify provides geo data automatically via context.geo
const geo = context.geo;
const data = {
city: geo?.city || null,
country: geo?.country?.code || null,
countryName: geo?.country?.name || null,
latitude: geo?.latitude || null,
longitude: geo?.longitude || null,
};
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "private, max-age=3600",
"Access-Control-Allow-Origin": "*",
},
});
}
export const config = {
path: "/api/geo",
};

BIN
public/images/122.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 560 KiB

View File

@@ -7,6 +7,62 @@ Date: 2025-12-21
All notable changes to this project. All notable changes to this project.
## v1.20.2
Released December 21, 2025
**Write conflict prevention for heartbeat mutation**
- Increased backend dedup window from 10s to 20s
- Increased frontend debounce from 10s to 20s to match backend
- Added random jitter (±5s) to heartbeat intervals to prevent synchronized calls across tabs
- Simplified early return to skip ANY update within dedup window
- Prevents "Documents read from or written to the activeSessions table changed" errors
## v1.20.1
Released December 21, 2025
**Visitor map styling improvements**
- Removed box-shadow from map wrapper for cleaner flat design
- Increased land dot contrast for better globe visibility on all themes
- Increased land dot opacity from 0.6 to 0.85
- Darker/more visible land colors for light, tan, and cloud themes
- Lighter land color for dark theme to stand out on dark background
## v1.20.0
Released December 21, 2025
**Real-time visitor map on stats page**
- Displays live visitor locations on a dotted world map
- Uses Netlify's built-in geo detection via edge function (no third-party API needed)
- Privacy friendly: stores city, country, and coordinates only (no IP addresses)
- Theme-aware colors for all four themes (dark, light, tan, cloud)
- Animated pulsing dots for active visitors
- Configurable via `siteConfig.visitorMap`
New files:
- `netlify/edge-functions/geo.ts`: Edge function returning geo data from Netlify headers
- `src/components/VisitorMap.tsx`: SVG world map component with visitor dots
Configuration:
```typescript
// src/config/siteConfig.ts
visitorMap: {
enabled: true, // Set to false to hide
title: "Live Visitors", // Optional title above the map
},
```
Updated files: `convex/schema.ts`, `convex/stats.ts`, `src/hooks/usePageTracking.ts`, `src/pages/Stats.tsx`, `src/config/siteConfig.ts`, `src/styles/global.css`
Documentation updated: setup-guide.md, docs.md, FORK_CONFIG.md, fork-config.json.example, fork-configuration-guide.md
## v1.19.2 ## v1.19.2
Released December 21, 2025 Released December 21, 2025

View File

@@ -360,6 +360,24 @@ gitHubContributions: {
Theme-aware colors match each site theme. Uses public API (no GitHub token required). Theme-aware colors match each site theme. Uses public API (no GitHub token required).
### Visitor map
Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored.
```typescript
visitorMap: {
enabled: true, // Set to false to hide
title: "Live Visitors", // Optional title above the map
},
```
| Option | Description |
| --------- | ------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `title` | Text above map (`undefined` to hide) |
The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge.
### Logo gallery ### Logo gallery
The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL. The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL.

View File

@@ -123,6 +123,10 @@ The JSON config file supports additional options:
"linkToProfile": true, "linkToProfile": true,
"title": "GitHub Activity" "title": "GitHub Activity"
}, },
"visitorMap": {
"enabled": true,
"title": "Live Visitors"
},
"blogPage": { "blogPage": {
"enabled": true, "enabled": true,
"showInNav": true, "showInNav": true,

View File

@@ -53,6 +53,7 @@ This guide walks you through forking [this markdown framework](https://github.co
- [Update Site Configuration](#update-site-configuration) - [Update Site Configuration](#update-site-configuration)
- [Featured Section](#featured-section) - [Featured Section](#featured-section)
- [GitHub Contributions Graph](#github-contributions-graph) - [GitHub Contributions Graph](#github-contributions-graph)
- [Visitor Map](#visitor-map)
- [Logo Gallery](#logo-gallery) - [Logo Gallery](#logo-gallery)
- [Blog page](#blog-page) - [Blog page](#blog-page)
- [Scroll-to-top button](#scroll-to-top-button) - [Scroll-to-top button](#scroll-to-top-button)
@@ -694,6 +695,26 @@ gitHubContributions: {
The graph displays with theme-aware colors that match each site theme (dark, light, tan, cloud). Uses the public `github-contributions-api.jogruber.de` API (no GitHub token required). The graph displays with theme-aware colors that match each site theme (dark, light, tan, cloud). Uses the public `github-contributions-api.jogruber.de` API (no GitHub token required).
### Visitor Map
Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored.
Configure in `siteConfig`:
```typescript
visitorMap: {
enabled: true, // Set to false to hide the visitor map
title: "Live Visitors", // Optional title above the map
},
```
| Option | Description |
| --------- | ------------------------------------------- |
| `enabled` | `true` to show, `false` to hide |
| `title` | Text above map (set to `undefined` to hide) |
The map displays with theme-aware colors. Visitor dots pulse to indicate live sessions. Location data comes from Netlify's automatic geo headers at the edge.
### Logo Gallery ### Logo Gallery
The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Customize or disable it in siteConfig: The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Customize or disable it in siteConfig:

View File

@@ -0,0 +1,128 @@
# Visitor tracking and stats improvements
> Real-time visitor map, write conflict prevention, GitHub Stars integration, and better AI prompts. Updates from v1.18.1 to v1.20.2.
---
Type: post
Date: 2025-12-21
Reading time: 5 min read
Tags: features, stats, convex, updates, analytics
---
## Real-time visitor map
The stats page now shows a live world map with visitor locations. Each active visitor appears as a pulsing dot on the map.
The map uses Netlify's built-in geo detection. No third-party API needed. No IP addresses stored. Just city, country, and coordinates.
Configure it in siteConfig:
```typescript
visitorMap: {
enabled: true,
title: "Live Visitors",
},
```
The map works with all four themes. Dark, light, tan, cloud. Land dots use theme-aware colors. Visitor dots pulse to show activity.
Implementation details:
- New edge function at `netlify/edge-functions/geo.ts` reads Netlify geo headers
- React component `VisitorMap.tsx` renders an SVG world map
- No external map library required
- Responsive design scales on mobile
The map updates in real time as visitors navigate your site. Each heartbeat includes location data. The map aggregates active sessions and displays them on the globe.
## Write conflict prevention
Convex write conflicts happen when multiple mutations update the same document concurrently. The heartbeat mutation was hitting this with rapid page navigation.
Version 1.20.2 fixes this with three changes:
1. Increased dedup window from 10 seconds to 20 seconds
2. Added random jitter (±5 seconds) to heartbeat intervals
3. Simplified early return to skip any update within the dedup window
The jitter prevents synchronized calls across browser tabs. If you have multiple tabs open, they no longer fire heartbeats at the same time.
Backend and frontend now use matching 20-second windows. The backend checks if a session was updated recently. If yes, it returns early without patching. This makes the mutation idempotent and prevents conflicts.
## GitHub Stars integration
The stats page now displays your repository's star count. Fetches from GitHub's public API. No token required.
The card shows the live count and updates on page load. Uses the Phosphor GithubLogo icon for consistency.
Stats page layout changed to accommodate six cards:
- Desktop: single row of six cards
- Tablet: 3x2 grid
- Mobile: 2x3 grid
- Small mobile: stacked
The GitHub Stars card sits alongside total page views, unique visitors, active visitors, and views by page.
## Author display
Posts and pages now support author attribution. Add these fields to frontmatter:
```yaml
authorName: "Your Name"
authorImage: "/images/authors/photo.png"
```
The author info appears on individual post and page views. Round avatar image next to the date and read time. Not shown on blog list views.
Place author images in `public/images/authors/`. Square images work best since they display as circles.
## Improved AI prompts
The CopyPageDropdown now sends better instructions to ChatGPT, Claude, and Perplexity.
Previous prompts asked AI to summarize without verifying the content loaded. New prompts instruct AI to:
1. Attempt to load the raw markdown URL
2. If successful, provide a concise summary and ask how to help
3. If not accessible, state the page could not be loaded without guessing
This prevents AI from hallucinating content when URLs fail to load. More reliable results for users sharing pages.
## Raw markdown URLs
Version 1.18.1 changed AI services to use raw markdown file URLs instead of page URLs. Format: `/raw/{slug}.md`.
AI services can fetch and parse clean markdown directly. No HTML parsing required. Includes metadata headers for structured parsing.
The raw markdown files are generated during `npm run sync`. Each published post and page gets a corresponding static `.md` file in `public/raw/`.
## Technical notes
All updates maintain backward compatibility. Existing sites continue working without changes.
The visitor map requires Netlify edge functions. The geo endpoint reads automatic headers from Netlify's CDN. No configuration needed beyond enabling the feature.
Write conflict prevention uses Convex best practices:
- Idempotent mutations with early returns
- Indexed queries for efficient lookups
- Event records pattern for high-frequency updates
- Debouncing on the frontend
The stats page aggregates data from multiple sources:
- Active sessions from heartbeat mutations
- Page views from event records
- GitHub Stars from public API
- Visitor locations from geo edge function
All data updates in real time via Convex subscriptions.
## What's next
These updates focus on making stats more useful and reliable. The visitor map provides visual feedback. Write conflict prevention ensures accuracy. GitHub Stars adds social proof.
Future updates will continue improving the analytics experience. More granular stats. Better visualization. More configuration options.
Check the changelog for the full list of changes from v1.18.1 to v1.20.2.

View File

@@ -0,0 +1,168 @@
import { useMemo } from "react";
// Visitor location data from Convex
interface VisitorLocation {
latitude: number;
longitude: number;
city?: string;
country?: string;
}
interface VisitorMapProps {
locations: VisitorLocation[];
title?: string;
}
// Simplified world map as dot grid (major landmass coordinates)
// This creates a lightweight dotted world map pattern
const WORLD_DOTS = generateWorldDots();
function generateWorldDots(): Array<{ x: number; y: number }> {
// Generate a simplified dot pattern for major landmasses
// Using approximate continent boundaries for a clean look
const dots: Array<{ x: number; y: number }> = [];
// Simplified continent outlines as dot regions
const regions = [
// North America
{ latMin: 25, latMax: 70, lngMin: -170, lngMax: -50, density: 0.15 },
// South America
{ latMin: -55, latMax: 12, lngMin: -80, lngMax: -35, density: 0.12 },
// Europe
{ latMin: 35, latMax: 70, lngMin: -10, lngMax: 40, density: 0.18 },
// Africa
{ latMin: -35, latMax: 37, lngMin: -18, lngMax: 52, density: 0.12 },
// Asia
{ latMin: 5, latMax: 75, lngMin: 40, lngMax: 145, density: 0.1 },
// Australia
{ latMin: -45, latMax: -10, lngMin: 112, lngMax: 155, density: 0.1 },
// Greenland
{ latMin: 60, latMax: 83, lngMin: -73, lngMax: -12, density: 0.08 },
];
// Generate dots for each region
for (const region of regions) {
const latStep = 4;
const lngStep = 6;
for (let lat = region.latMin; lat <= region.latMax; lat += latStep) {
for (let lng = region.lngMin; lng <= region.lngMax; lng += lngStep) {
// Add some randomness to make it look more natural
if (Math.random() < region.density * 3) {
const { x, y } = latLngToSvg(lat, lng);
dots.push({ x, y });
}
}
}
}
return dots;
}
// Convert latitude/longitude to SVG coordinates (simple mercator projection)
function latLngToSvg(lat: number, lng: number): { x: number; y: number } {
// Map dimensions: 800x400
const x = ((lng + 180) / 360) * 800;
const y = ((90 - lat) / 180) * 400;
return { x, y };
}
/**
* Visitor Map Component
* Displays a dotted world map with animated location indicators
* Theme-aware colors using CSS variables
*/
export default function VisitorMap({ locations, title }: VisitorMapProps) {
// Convert visitor locations to SVG coordinates
const visitorDots = useMemo(() => {
return locations.map((loc) => ({
...latLngToSvg(loc.latitude, loc.longitude),
city: loc.city,
country: loc.country,
}));
}, [locations]);
return (
<div className="visitor-map-container">
{title && <h2 className="visitor-map-title">{title}</h2>}
<div className="visitor-map-wrapper">
<svg
viewBox="0 0 800 400"
className="visitor-map-svg"
preserveAspectRatio="xMidYMid meet"
>
{/* Background */}
<rect
x="0"
y="0"
width="800"
height="400"
fill="var(--visitor-map-bg)"
rx="8"
/>
{/* World map dots (landmasses) */}
{WORLD_DOTS.map((dot, i) => (
<circle
key={`land-${i}`}
cx={dot.x}
cy={dot.y}
r="2"
fill="var(--visitor-map-land)"
opacity="0.85"
/>
))}
{/* Visitor location dots with pulse animation */}
{visitorDots.map((dot, i) => (
<g key={`visitor-${i}`}>
{/* Outer pulse ring */}
<circle
cx={dot.x}
cy={dot.y}
r="12"
fill="var(--visitor-map-dot)"
opacity="0"
className="visitor-pulse-ring"
style={{ animationDelay: `${i * 0.2}s` }}
/>
{/* Middle pulse ring */}
<circle
cx={dot.x}
cy={dot.y}
r="8"
fill="var(--visitor-map-dot)"
opacity="0.2"
className="visitor-pulse-ring-mid"
style={{ animationDelay: `${i * 0.2}s` }}
/>
{/* Solid center dot */}
<circle
cx={dot.x}
cy={dot.y}
r="5"
fill="var(--visitor-map-dot)"
className="visitor-dot-center"
/>
{/* Inner bright core */}
<circle
cx={dot.x}
cy={dot.y}
r="2"
fill="var(--visitor-map-dot-core)"
/>
</g>
))}
</svg>
{/* Location count badge */}
{locations.length > 0 && (
<div className="visitor-map-badge">
<span className="visitor-map-badge-dot" />
{locations.length} {locations.length === 1 ? "visitor" : "visitors"}{" "}
online
</div>
)}
</div>
</div>
);
}

View File

@@ -13,6 +13,13 @@ export interface GitHubContributionsConfig {
title?: string; // Optional title above the graph title?: string; // Optional title above the graph
} }
// Visitor map configuration
// Displays real-time visitor locations on a world map on the stats page
export interface VisitorMapConfig {
enabled: boolean; // Enable/disable the visitor map
title?: string; // Optional title above the map
}
// Blog page configuration // Blog page configuration
// Controls whether posts appear on homepage, dedicated blog page, or both // Controls whether posts appear on homepage, dedicated blog page, or both
export interface BlogPageConfig { export interface BlogPageConfig {
@@ -49,6 +56,9 @@ export interface SiteConfig {
// GitHub contributions graph configuration // GitHub contributions graph configuration
gitHubContributions: GitHubContributionsConfig; gitHubContributions: GitHubContributionsConfig;
// Visitor map configuration (stats page)
visitorMap: VisitorMapConfig;
// Blog page configuration // Blog page configuration
blogPage: BlogPageConfig; blogPage: BlogPageConfig;
@@ -125,6 +135,13 @@ export const siteConfig: SiteConfig = {
title: "GitHub Activity", // Optional title above the graph title: "GitHub Activity", // Optional title above the graph
}, },
// Visitor map configuration
// Displays real-time visitor locations on the stats page
visitorMap: {
enabled: true, // Set to false to hide the visitor map
title: "Live Visitors", // Optional title above the map
},
// Blog page configuration // Blog page configuration
// Set enabled to true to create a dedicated /blog page // Set enabled to true to create a dedicated /blog page
blogPage: { blogPage: {

View File

@@ -3,15 +3,26 @@ import { useMutation } from "convex/react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { api } from "../../convex/_generated/api"; import { api } from "../../convex/_generated/api";
// Heartbeat interval: 30 seconds // Heartbeat interval: 30 seconds (with jitter added to prevent synchronized calls)
const HEARTBEAT_INTERVAL_MS = 30 * 1000; const HEARTBEAT_INTERVAL_MS = 30 * 1000;
// Minimum time between heartbeats to prevent write conflicts: 10 seconds (matches backend dedup window) // Minimum time between heartbeats to prevent write conflicts: 20 seconds (matches backend dedup window)
const HEARTBEAT_DEBOUNCE_MS = 10 * 1000; const HEARTBEAT_DEBOUNCE_MS = 20 * 1000;
// Jitter range: ±5 seconds to prevent synchronized heartbeats across tabs
const HEARTBEAT_JITTER_MS = 5 * 1000;
// Session ID key in localStorage // Session ID key in localStorage
const SESSION_ID_KEY = "markdown_blog_session_id"; const SESSION_ID_KEY = "markdown_blog_session_id";
// Geo data interface from Netlify edge function
interface GeoData {
city?: string;
country?: string;
latitude?: number;
longitude?: number;
}
/** /**
* Generate a random session ID (UUID v4 format) * Generate a random session ID (UUID v4 format)
*/ */
@@ -55,6 +66,7 @@ function getPageType(path: string): string {
/** /**
* Hook to track page views and maintain active session presence * Hook to track page views and maintain active session presence
* Fetches geo location from Netlify edge function for visitor map
*/ */
export function usePageTracking(): void { export function usePageTracking(): void {
const location = useLocation(); const location = useLocation();
@@ -70,9 +82,47 @@ export function usePageTracking(): void {
const lastHeartbeatTime = useRef(0); const lastHeartbeatTime = useRef(0);
const lastHeartbeatPath = useRef<string | null>(null); const lastHeartbeatPath = useRef<string | null>(null);
// Initialize session ID // Geo data ref (fetched once on mount)
const geoDataRef = useRef<GeoData | null>(null);
const geoFetchedRef = useRef(false);
// Initialize session ID and fetch geo data once on mount
useEffect(() => { useEffect(() => {
sessionIdRef.current = getSessionId(); sessionIdRef.current = getSessionId();
// Fetch geo data once (skip if already fetched)
if (!geoFetchedRef.current) {
geoFetchedRef.current = true;
// Check if running on localhost (edge functions don't work locally)
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
if (isLocalhost) {
// Use mock geo data for localhost testing
// This allows the visitor map to work during local development
geoDataRef.current = {
city: "San Francisco",
country: "US",
latitude: 37.7749,
longitude: -122.4194,
};
} else {
// Fetch real geo data from Netlify edge function in production
fetch("/api/geo")
.then((res) => res.json())
.then((data: GeoData) => {
// Only store if we have valid coordinates
if (data.latitude && data.longitude) {
geoDataRef.current = data;
}
})
.catch(() => {
// Silently fail - geo data is optional
});
}
}
}, []); }, []);
// Debounced heartbeat function to prevent write conflicts // Debounced heartbeat function to prevent write conflicts
@@ -101,9 +151,15 @@ export function usePageTracking(): void {
lastHeartbeatPath.current = path; lastHeartbeatPath.current = path;
try { try {
// Include geo data if available
const geo = geoDataRef.current;
await heartbeatMutation({ await heartbeatMutation({
sessionId, sessionId,
currentPath: path, currentPath: path,
...(geo?.city && { city: geo.city }),
...(geo?.country && { country: geo.country }),
...(geo?.latitude && { latitude: geo.latitude }),
...(geo?.longitude && { longitude: geo.longitude }),
}); });
} catch { } catch {
// Silently fail - analytics shouldn't break the app // Silently fail - analytics shouldn't break the app
@@ -139,17 +195,35 @@ export function usePageTracking(): void {
useEffect(() => { useEffect(() => {
const path = location.pathname; const path = location.pathname;
// Send initial heartbeat for this path // Add random jitter to initial delay to prevent synchronized heartbeats across tabs
sendHeartbeat(path); const initialJitter = Math.random() * HEARTBEAT_JITTER_MS;
// Set up interval for ongoing heartbeats // Send initial heartbeat after jitter delay
const intervalId = setInterval(() => { const initialTimeoutId = setTimeout(() => {
sendHeartbeat(path); sendHeartbeat(path);
}, HEARTBEAT_INTERVAL_MS); }, initialJitter);
// Set up interval for ongoing heartbeats with jitter
// Using recursive setTimeout instead of setInterval for variable timing
let timeoutId: ReturnType<typeof setTimeout>;
const scheduleNextHeartbeat = () => {
const jitter = (Math.random() - 0.5) * 2 * HEARTBEAT_JITTER_MS; // ±5 seconds
const nextDelay = HEARTBEAT_INTERVAL_MS + jitter;
timeoutId = setTimeout(() => {
sendHeartbeat(path);
scheduleNextHeartbeat();
}, nextDelay);
};
// Start the heartbeat loop after initial heartbeat
const loopTimeoutId = setTimeout(() => {
scheduleNextHeartbeat();
}, initialJitter + HEARTBEAT_INTERVAL_MS);
return () => { return () => {
clearInterval(intervalId); clearTimeout(initialTimeoutId);
clearTimeout(loopTimeoutId);
clearTimeout(timeoutId);
}; };
}, [location.pathname, sendHeartbeat]); }, [location.pathname, sendHeartbeat]);
} }

View File

@@ -11,6 +11,8 @@ import {
Activity, Activity,
} from "lucide-react"; } from "lucide-react";
import { GithubLogo } from "@phosphor-icons/react"; import { GithubLogo } from "@phosphor-icons/react";
import VisitorMap from "../components/VisitorMap";
import siteConfig from "../config/siteConfig";
// Site launched Dec 14, 2025 at 1:00 PM (v1.0.0), stats added same day (v1.2.0) // Site launched Dec 14, 2025 at 1:00 PM (v1.0.0), stats added same day (v1.2.0)
const SITE_LAUNCH_DATE = "Dec 14, 2025 at 1:00 PM"; const SITE_LAUNCH_DATE = "Dec 14, 2025 at 1:00 PM";
@@ -141,6 +143,14 @@ export default function Stats() {
})} })}
</section> </section>
{/* Visitor map showing real-time locations */}
{siteConfig.visitorMap.enabled && stats.visitorLocations.length > 0 && (
<VisitorMap
locations={stats.visitorLocations}
title={siteConfig.visitorMap.title}
/>
)}
{/* Active visitors by page */} {/* Active visitors by page */}
{stats.activeByPath.length > 0 && ( {stats.activeByPath.length > 0 && (
<section className="stats-section-wide"> <section className="stats-section-wide">

View File

@@ -2648,6 +2648,181 @@ body {
} }
} }
/* ============================================
VISITOR MAP STYLES
============================================
World map showing real-time visitor locations
Uses Netlify geo data, privacy friendly (no IP stored)
============================================ */
/* Visitor map theme colors */
:root[data-theme="dark"] {
--visitor-map-bg: #141414;
--visitor-map-land: #4a4a4a;
--visitor-map-dot: #60a5fa;
--visitor-map-dot-core: #93c5fd;
}
:root[data-theme="light"] {
--visitor-map-bg: #fafafa;
--visitor-map-land: #9ca3af;
--visitor-map-dot: #3b82f6;
--visitor-map-dot-core: #60a5fa;
}
:root[data-theme="tan"] {
--visitor-map-bg: #f3f1ed;
--visitor-map-land: #b8a892;
--visitor-map-dot: #8b7355;
--visitor-map-dot-core: #b5986d;
}
:root[data-theme="cloud"] {
--visitor-map-bg: #f0f4f8;
--visitor-map-land: #94a3b8;
--visitor-map-dot: #4682b4;
--visitor-map-dot-core: #7ba3c9;
}
/* Visitor map container */
.visitor-map-container {
margin-bottom: 32px;
}
.visitor-map-title {
font-size: var(--font-size-stats-section-title);
color: var(--text-primary);
margin-bottom: 16px;
font-weight: 600;
}
.visitor-map-wrapper {
position: relative;
border-radius: 12px;
overflow: hidden;
background: var(--visitor-map-bg);
border: 1px solid var(--border-color);
}
.visitor-map-svg {
display: block;
width: 100%;
height: auto;
max-height: 300px;
}
/* Pulse animation for visitor dots */
@keyframes visitor-pulse {
0% {
r: 5;
opacity: 0.6;
}
50% {
r: 18;
opacity: 0;
}
100% {
r: 5;
opacity: 0;
}
}
@keyframes visitor-pulse-mid {
0% {
r: 5;
opacity: 0.3;
}
50% {
r: 12;
opacity: 0;
}
100% {
r: 5;
opacity: 0;
}
}
.visitor-pulse-ring {
animation: visitor-pulse 2.5s ease-out infinite;
}
.visitor-pulse-ring-mid {
animation: visitor-pulse-mid 2.5s ease-out infinite;
animation-delay: 0.3s;
}
.visitor-dot-center {
filter: drop-shadow(0 0 4px var(--visitor-map-dot));
}
/* Visitor count badge */
.visitor-map-badge {
position: absolute;
bottom: 12px;
left: 12px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 20px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.visitor-map-badge-dot {
width: 8px;
height: 8px;
background: var(--visitor-map-dot);
border-radius: 50%;
animation: badge-pulse 2s ease-in-out infinite;
}
@keyframes badge-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* No box-shadow on visitor map wrapper - clean flat design */
/* Responsive adjustments */
@media (max-width: 768px) {
.visitor-map-container {
margin-bottom: 24px;
}
.visitor-map-svg {
max-height: 200px;
}
.visitor-map-badge {
font-size: var(--font-size-xs);
padding: 4px 10px;
gap: 6px;
}
.visitor-map-badge-dot {
width: 6px;
height: 6px;
}
}
@media (max-width: 480px) {
.visitor-map-svg {
max-height: 160px;
}
.visitor-map-badge {
bottom: 8px;
left: 8px;
}
}
/* Logo marquee container */ /* Logo marquee container */
.logo-marquee-container { .logo-marquee-container {
margin: 48px 0; margin: 48px 0;