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
@@ -132,6 +132,12 @@ export const siteConfig: SiteConfig = {
|
||||
title: "GitHub Activity",
|
||||
},
|
||||
|
||||
// Visitor map (stats page)
|
||||
visitorMap: {
|
||||
enabled: true,
|
||||
title: "Live Visitors",
|
||||
},
|
||||
|
||||
// Blog page
|
||||
blogPage: {
|
||||
enabled: true,
|
||||
|
||||
20
README.md
@@ -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).
|
||||
|
||||
## 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
|
||||
|
||||
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
@@ -4,10 +4,20 @@
|
||||
|
||||
## 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
|
||||
|
||||
- [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] Round avatar image displayed next to date and read time on post/page views
|
||||
- [x] Write page updated with new frontmatter field reference
|
||||
|
||||
58
changelog.md
@@ -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/).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -128,6 +128,10 @@ The JSON config file supports additional options:
|
||||
"linkToProfile": true,
|
||||
"title": "GitHub Activity"
|
||||
},
|
||||
"visitorMap": {
|
||||
"enabled": true,
|
||||
"title": "Live Visitors"
|
||||
},
|
||||
"blogPage": {
|
||||
"enabled": true,
|
||||
"showInNav": true,
|
||||
|
||||
@@ -6,7 +6,7 @@ slug: "new-features-search-featured-logos"
|
||||
published: true
|
||||
tags: ["features", "search", "convex", "updates"]
|
||||
readTime: "4 min read"
|
||||
featured: true
|
||||
featured: false
|
||||
featuredOrder: 5
|
||||
authorName: "Markdown"
|
||||
authorImage: "/images/authors/markdown.png"
|
||||
|
||||
@@ -7,7 +7,7 @@ published: true
|
||||
tags: ["convex", "netlify", "tutorial", "deployment"]
|
||||
readTime: "8 min read"
|
||||
featured: true
|
||||
featuredOrder: 3
|
||||
featuredOrder: 6
|
||||
image: "/images/setupguide.png"
|
||||
authorName: "Markdown"
|
||||
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)
|
||||
- [Featured Section](#featured-section)
|
||||
- [GitHub Contributions Graph](#github-contributions-graph)
|
||||
- [Visitor Map](#visitor-map)
|
||||
- [Logo Gallery](#logo-gallery)
|
||||
- [Blog page](#blog-page)
|
||||
- [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).
|
||||
|
||||
### 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
|
||||
|
||||
The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Customize or disable it in siteConfig:
|
||||
|
||||
@@ -5,7 +5,7 @@ date: "2025-12-14"
|
||||
slug: "using-images-in-posts"
|
||||
published: true
|
||||
featured: true
|
||||
featuredOrder: 3
|
||||
featuredOrder: 4
|
||||
tags: ["images", "tutorial", "markdown", "open-graph"]
|
||||
readTime: "4 min read"
|
||||
authorName: "Markdown"
|
||||
|
||||
133
content/blog/visitor-tracking-and-stats-improvements.md
Normal 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.
|
||||
@@ -7,6 +7,62 @@ order: 5
|
||||
|
||||
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
|
||||
|
||||
Released December 21, 2025
|
||||
|
||||
@@ -360,6 +360,24 @@ gitHubContributions: {
|
||||
|
||||
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
|
||||
|
||||
The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL.
|
||||
|
||||
@@ -88,6 +88,11 @@ export default defineSchema({
|
||||
sessionId: v.string(),
|
||||
currentPath: v.string(),
|
||||
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_lastSeen", ["lastSeen"]),
|
||||
|
||||
@@ -10,8 +10,8 @@ const DEDUP_WINDOW_MS = 30 * 60 * 1000;
|
||||
// Session timeout: 2 minutes in milliseconds
|
||||
const SESSION_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
// Heartbeat dedup window: 10 seconds (prevents write conflicts from rapid calls)
|
||||
const HEARTBEAT_DEDUP_MS = 10 * 1000;
|
||||
// Heartbeat dedup window: 20 seconds (prevents write conflicts from rapid calls or multiple tabs)
|
||||
const HEARTBEAT_DEDUP_MS = 20 * 1000;
|
||||
|
||||
/**
|
||||
* Aggregate for page views by path.
|
||||
@@ -73,7 +73,7 @@ export const recordPageView = mutation({
|
||||
const recentView = await ctx.db
|
||||
.query("pageViews")
|
||||
.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")
|
||||
.first();
|
||||
@@ -116,12 +116,23 @@ export const recordPageView = mutation({
|
||||
/**
|
||||
* Update active session heartbeat.
|
||||
* 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).
|
||||
*
|
||||
* 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({
|
||||
args: {
|
||||
sessionId: 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(),
|
||||
handler: async (ctx, args) => {
|
||||
@@ -134,27 +145,33 @@ export const heartbeat = mutation({
|
||||
.first();
|
||||
|
||||
if (existingSession) {
|
||||
// Early return if same path and recently updated (idempotent - prevents write conflicts)
|
||||
if (
|
||||
existingSession.currentPath === args.currentPath &&
|
||||
now - existingSession.lastSeen < HEARTBEAT_DEDUP_MS
|
||||
) {
|
||||
// Early return if recently updated (idempotent - prevents write conflicts)
|
||||
// Even if path changed, skip update if within dedup window to reduce conflicts
|
||||
if (now - existingSession.lastSeen < HEARTBEAT_DEDUP_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Patch directly with new data
|
||||
// Patch directly with new data including location if provided
|
||||
await ctx.db.patch(existingSession._id, {
|
||||
currentPath: args.currentPath,
|
||||
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;
|
||||
}
|
||||
|
||||
// Create new session only if none exists
|
||||
// Create new session only if none exists (with location data if provided)
|
||||
await ctx.db.insert("activeSessions", {
|
||||
sessionId: args.sessionId,
|
||||
currentPath: args.currentPath,
|
||||
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;
|
||||
@@ -165,6 +182,7 @@ export const heartbeat = mutation({
|
||||
* Get all stats for the stats page.
|
||||
* Real-time subscription via useQuery.
|
||||
* 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({
|
||||
args: {},
|
||||
@@ -174,7 +192,7 @@ export const getStats = query({
|
||||
v.object({
|
||||
path: v.string(),
|
||||
count: v.number(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
totalPageViews: v.number(),
|
||||
uniqueVisitors: v.number(),
|
||||
@@ -187,7 +205,16 @@ export const getStats = query({
|
||||
title: v.string(),
|
||||
pageType: v.string(),
|
||||
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) => {
|
||||
@@ -280,9 +307,25 @@ export const getStats = query({
|
||||
});
|
||||
|
||||
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 {
|
||||
activeVisitors: activeSessions.length,
|
||||
activeByPath,
|
||||
@@ -292,6 +335,7 @@ export const getStats = query({
|
||||
publishedPages: pages.length,
|
||||
trackingSince,
|
||||
pageStats,
|
||||
visitorLocations,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -313,7 +357,9 @@ export const cleanupStaleSessions = internalMutation({
|
||||
.collect();
|
||||
|
||||
// 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;
|
||||
},
|
||||
@@ -376,12 +422,13 @@ export const backfillAggregatesChunk = internalMutation({
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
// 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,
|
||||
totalProcessed: newTotalProcessed,
|
||||
seenSessionIds: sessionArray,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -423,15 +470,15 @@ export const backfillAggregates = internalMutation({
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
// 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,
|
||||
totalProcessed: 0,
|
||||
seenSessionIds: [],
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return { message: "Backfill started. Check logs for progress." };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
2
files.md
@@ -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 |
|
||||
| `ScrollToTop.tsx` | Configurable scroll-to-top button with Phosphor ArrowUp icon |
|
||||
| `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/`)
|
||||
|
||||
@@ -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 |
|
||||
| `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP |
|
||||
| `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/`)
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
"linkToProfile": true,
|
||||
"title": "GitHub Activity"
|
||||
},
|
||||
"visitorMap": {
|
||||
"enabled": true,
|
||||
"title": "Live Visitors"
|
||||
},
|
||||
"blogPage": {
|
||||
"enabled": true,
|
||||
"showInNav": true,
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
path = "/api/export"
|
||||
function = "api"
|
||||
|
||||
# Geo location API for visitor map
|
||||
[[edge_functions]]
|
||||
path = "/api/geo"
|
||||
function = "geo"
|
||||
|
||||
# Open Graph bot detection (catches all other routes)
|
||||
[[edge_functions]]
|
||||
path = "/*"
|
||||
|
||||
32
netlify/edge-functions/geo.ts
Normal 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
|
After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 295 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 560 KiB |
@@ -7,6 +7,62 @@ Date: 2025-12-21
|
||||
|
||||
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
|
||||
|
||||
Released December 21, 2025
|
||||
|
||||
@@ -360,6 +360,24 @@ gitHubContributions: {
|
||||
|
||||
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
|
||||
|
||||
The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Each logo can link to a URL.
|
||||
|
||||
@@ -123,6 +123,10 @@ The JSON config file supports additional options:
|
||||
"linkToProfile": true,
|
||||
"title": "GitHub Activity"
|
||||
},
|
||||
"visitorMap": {
|
||||
"enabled": true,
|
||||
"title": "Live Visitors"
|
||||
},
|
||||
"blogPage": {
|
||||
"enabled": true,
|
||||
"showInNav": true,
|
||||
|
||||
@@ -53,6 +53,7 @@ This guide walks you through forking [this markdown framework](https://github.co
|
||||
- [Update Site Configuration](#update-site-configuration)
|
||||
- [Featured Section](#featured-section)
|
||||
- [GitHub Contributions Graph](#github-contributions-graph)
|
||||
- [Visitor Map](#visitor-map)
|
||||
- [Logo Gallery](#logo-gallery)
|
||||
- [Blog page](#blog-page)
|
||||
- [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).
|
||||
|
||||
### 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
|
||||
|
||||
The homepage includes a logo gallery that can scroll infinitely or display as a static grid. Customize or disable it in siteConfig:
|
||||
|
||||
128
public/raw/visitor-tracking-and-stats-improvements.md
Normal 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.
|
||||
168
src/components/VisitorMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,13 @@ export interface GitHubContributionsConfig {
|
||||
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
|
||||
// Controls whether posts appear on homepage, dedicated blog page, or both
|
||||
export interface BlogPageConfig {
|
||||
@@ -49,6 +56,9 @@ export interface SiteConfig {
|
||||
// GitHub contributions graph configuration
|
||||
gitHubContributions: GitHubContributionsConfig;
|
||||
|
||||
// Visitor map configuration (stats page)
|
||||
visitorMap: VisitorMapConfig;
|
||||
|
||||
// Blog page configuration
|
||||
blogPage: BlogPageConfig;
|
||||
|
||||
@@ -125,6 +135,13 @@ export const siteConfig: SiteConfig = {
|
||||
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
|
||||
// Set enabled to true to create a dedicated /blog page
|
||||
blogPage: {
|
||||
|
||||
@@ -3,15 +3,26 @@ import { useMutation } from "convex/react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
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;
|
||||
|
||||
// Minimum time between heartbeats to prevent write conflicts: 10 seconds (matches backend dedup window)
|
||||
const HEARTBEAT_DEBOUNCE_MS = 10 * 1000;
|
||||
// Minimum time between heartbeats to prevent write conflicts: 20 seconds (matches backend dedup window)
|
||||
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
|
||||
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)
|
||||
*/
|
||||
@@ -55,6 +66,7 @@ function getPageType(path: string): string {
|
||||
|
||||
/**
|
||||
* Hook to track page views and maintain active session presence
|
||||
* Fetches geo location from Netlify edge function for visitor map
|
||||
*/
|
||||
export function usePageTracking(): void {
|
||||
const location = useLocation();
|
||||
@@ -70,9 +82,47 @@ export function usePageTracking(): void {
|
||||
const lastHeartbeatTime = useRef(0);
|
||||
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(() => {
|
||||
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
|
||||
@@ -101,9 +151,15 @@ export function usePageTracking(): void {
|
||||
lastHeartbeatPath.current = path;
|
||||
|
||||
try {
|
||||
// Include geo data if available
|
||||
const geo = geoDataRef.current;
|
||||
await heartbeatMutation({
|
||||
sessionId,
|
||||
currentPath: path,
|
||||
...(geo?.city && { city: geo.city }),
|
||||
...(geo?.country && { country: geo.country }),
|
||||
...(geo?.latitude && { latitude: geo.latitude }),
|
||||
...(geo?.longitude && { longitude: geo.longitude }),
|
||||
});
|
||||
} catch {
|
||||
// Silently fail - analytics shouldn't break the app
|
||||
@@ -139,17 +195,35 @@ export function usePageTracking(): void {
|
||||
useEffect(() => {
|
||||
const path = location.pathname;
|
||||
|
||||
// Send initial heartbeat for this path
|
||||
sendHeartbeat(path);
|
||||
// Add random jitter to initial delay to prevent synchronized heartbeats across tabs
|
||||
const initialJitter = Math.random() * HEARTBEAT_JITTER_MS;
|
||||
|
||||
// Set up interval for ongoing heartbeats
|
||||
const intervalId = setInterval(() => {
|
||||
// Send initial heartbeat after jitter delay
|
||||
const initialTimeoutId = setTimeout(() => {
|
||||
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 () => {
|
||||
clearInterval(intervalId);
|
||||
clearTimeout(initialTimeoutId);
|
||||
clearTimeout(loopTimeoutId);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [location.pathname, sendHeartbeat]);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
Activity,
|
||||
} from "lucide-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)
|
||||
const SITE_LAUNCH_DATE = "Dec 14, 2025 at 1:00 PM";
|
||||
@@ -141,6 +143,14 @@ export default function Stats() {
|
||||
})}
|
||||
</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 */}
|
||||
{stats.activeByPath.length > 0 && (
|
||||
<section className="stats-section-wide">
|
||||
|
||||
@@ -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 {
|
||||
margin: 48px 0;
|
||||
|
||||