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",
|
title: "GitHub Activity",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Visitor map (stats page)
|
||||||
|
visitorMap: {
|
||||||
|
enabled: true,
|
||||||
|
title: "Live Visitors",
|
||||||
|
},
|
||||||
|
|
||||||
// Blog page
|
// Blog page
|
||||||
blogPage: {
|
blogPage: {
|
||||||
enabled: true,
|
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).
|
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
@@ -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
|
||||||
|
|||||||
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/).
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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.
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"]),
|
||||||
|
|||||||
@@ -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." };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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 |
|
| `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/`)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = "/*"
|
||||||
|
|||||||
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.
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
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
|
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: {
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||