Changelog - 2025-12-25 (#5)
Dark Matter Site: Session Duration Tracking with Heartbeat Pattern
Overview
Implemented a complete session duration tracking system that records how long users spend viewing confidential portfolio content. The entire implementation uses zero external libraries - just vanilla JavaScript and native browser APIs.
The surprise: This took about 15 minutes to implement end-to-end, with full NocoDB integration, and it's more reliable than most commercial analytics solutions for this specific use case.
Key deliverables:
- Heartbeat API Endpoint: Server receives periodic "I'm still here" pings
- Client-Side Heartbeat Script: Vanilla JS component, no dependencies
- NocoDB Integration: Sessions stored with start time and rolling end time
- Visibility-Aware: Pauses when tab is hidden, resumes when visible
- Multiple Sessions: Same user can have unlimited tracked sessions
- Blueprint Documentation: Full pattern documentation for reuse
Why Heartbeat?
Traditional session tracking approaches are unreliable:
| Approach | Problem |
|---|---|
beforeunload event |
Often blocked on mobile, doesn't fire on crash/force quit |
unload event |
Deprecated, same reliability issues |
| Server-side timeout | Requires sticky sessions, complex infrastructure |
| WebSocket ping | Overkill, requires WebSocket server |
Heartbeat insight: You don't need to know exactly when the user left. You just need to know when they were last seen. If heartbeats stop, the session ended ~3 minutes ago.
Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ Session Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. USER SUBMITS EMAIL │
│ └──► POST /api/verify-temp-access │
│ │ │
│ ├──► Create NocoDB record (sessionStartTime = now) │
│ ├──► Store record ID in cookie │
│ └──► Redirect to /portfolio/confidential │
│ │
│ 2. PAGE LOADS WITH <SessionHeartbeat /> │
│ └──► Client reads session_record_id cookie │
│ └──► Sends initial heartbeat immediately │
│ └──► Starts 3-minute interval timer │
│ │
│ 3. EVERY 3 MINUTES (while tab visible) │
│ └──► POST /api/session-heartbeat │
│ └──► PATCH NocoDB: sessionEndTime = now │
│ │
│ 4. TAB BECOMES HIDDEN │
│ └──► Heartbeat pauses (saves bandwidth) │
│ │
│ 5. TAB BECOMES VISIBLE AGAIN │
│ └──► Immediate heartbeat + resume interval │
│ │
│ 6. USER CLOSES TAB / NAVIGATES AWAY │
│ └──► sendBeacon fires final heartbeat (best effort) │
│ └──► Heartbeats stop → sessionEndTime frozen at last value │
│ │
│ RESULT: sessionEndTime - sessionStartTime = viewing duration │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Implementation
Files Created
| File | Lines | Purpose |
|---|---|---|
src/pages/api/session-heartbeat.ts |
60 | API endpoint receiving heartbeat pings |
src/components/auth/SessionHeartbeat.astro |
95 | Client-side heartbeat component |
context-v/blueprints/Using-Heartbeat-Patterns... |
350 | Full pattern documentation |
Files Modified
| File | Changes |
|---|---|
src/pages/api/verify-temp-access.ts |
Now awaits session creation, stores record ID in cookie |
src/lib/nocodb.ts |
Added updateSessionHeartbeat() function |
src/pages/portfolio/confidential/index.astro |
Added <SessionHeartbeat /> component |
Code Highlights
Client-Side Heartbeat (Zero Dependencies)
The entire client-side implementation is ~60 lines of vanilla JavaScript:
// No imports, no npm packages, no build step required
(function() {
const HEARTBEAT_INTERVAL = 3 * 60 * 1000; // 3 minutes
let isPageVisible = true;
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
async function sendHeartbeat() {
if (!getCookie('session_record_id')) return;
await fetch('/api/session-heartbeat', {
method: 'POST',
credentials: 'same-origin',
});
}
// Visibility-aware: pause when hidden, resume when visible
document.addEventListener('visibilitychange', () => {
isPageVisible = !document.hidden;
if (isPageVisible) sendHeartbeat();
});
// Best-effort final heartbeat
window.addEventListener('beforeunload', () => {
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/session-heartbeat');
}
});
// Start heartbeat
sendHeartbeat();
setInterval(() => isPageVisible && sendHeartbeat(), HEARTBEAT_INTERVAL);
})();
Server-Side Heartbeat Handler
export const POST: APIRoute = async ({ cookies }) => {
const recordId = cookies.get('session_record_id')?.value;
if (!recordId) return new Response('No session', { status: 400 });
await updateSessionHeartbeat(parseInt(recordId, 10));
return new Response(JSON.stringify({ success: true }));
};
NocoDB Update (Single PATCH)
export async function updateSessionHeartbeat(recordId: number) {
await fetch(nocodbUrl, {
method: 'PATCH',
headers: { 'xc-token': apiKey },
body: JSON.stringify([{
id: recordId,
sessionEndTime: new Date().toISOString(),
}]),
});
}
Browser APIs Used
All native, all modern browsers, zero polyfills:
| API | Purpose | Support |
|---|---|---|
fetch() |
HTTP requests | ✅ All modern |
setInterval() |
Timer | ✅ Universal |
document.cookie |
Read cookies | ✅ Universal |
navigator.sendBeacon() |
Reliable unload | ✅ All modern |
document.hidden |
Tab visibility | ✅ All modern |
visibilitychange event |
Detect tab switch | ✅ All modern |
NocoDB Data Model
Each session is a row in the emailAccess table:
┌─────────────────────┬──────────────────────┬──────────────────────┐
│ emailOfAccessor │ sessionStartTime │ sessionEndTime │
├─────────────────────┼──────────────────────┼──────────────────────┤
│ investor@fund.com │ 2025-12-25T10:00:00 │ 2025-12-25T10:47:00 │ ← 47 min
│ investor@fund.com │ 2025-12-25T14:30:00 │ 2025-12-25T14:38:00 │ ← 8 min
│ partner@vc.com │ 2025-12-25T11:00:00 │ 2025-12-25T11:22:00 │ ← 22 min
│ analyst@corp.com │ 2025-12-25T16:45:00 │ 2025-12-25T17:30:00 │ ← 45 min
└─────────────────────┴──────────────────────┴──────────────────────┘
Query examples:
// All sessions for a user
const sessions = await fetchEmailAccessSessions({ email: 'investor@fund.com' });
// Total time spent by user
const totalMinutes = sessions.reduce((sum, s) => {
const duration = new Date(s.fields.sessionEndTime) - new Date(s.fields.sessionStartTime);
return sum + duration / 60000;
}, 0);
// Result: 55 minutes (47 + 8)
Usage
Drop the component into any protected page:
---
import SessionHeartbeat from '@components/auth/SessionHeartbeat.astro';
---
<Layout>
<SessionHeartbeat />
<!-- Your confidential content -->
</Layout>
That's it. No configuration required. Session tracking just works.
Optional Props
<!-- Custom interval (default: 3 minutes) -->
<SessionHeartbeat interval={60000} /> <!-- 1 minute -->
<!-- Debug logging to console -->
<SessionHeartbeat debug={true} />
Why This Is Surprisingly Easy
- No state management: Cookies handle session ID storage
- No WebSockets: Simple HTTP POST is sufficient
- No build step: Vanilla JS works directly in Astro
- No external services: NocoDB handles persistence
- No complex timing: Just a setInterval that fires regularly
The entire feature is:
- 4 files (endpoint, component, lib function, docs)
- ~200 lines of code total
- 0 npm dependencies added
- ~15 minutes to implement
Accuracy & Limitations
Accuracy
- Best case: ±0 seconds (if user triggers visibility change or unload)
- Typical case: ±3 minutes (heartbeat interval)
- Worst case: ±3 minutes (if heartbeat fails silently)
For analytics purposes, ±3 minute accuracy is more than sufficient.
Limitations
- Requires cookies: Won't work if user blocks cookies
- Single page: Doesn't track navigation between pages (each page needs component)
- Network required: Heartbeats fail silently if offline
- Mobile reliability:
beforeunloadless reliable, but heartbeat compensates
Future Enhancements
If needed, the pattern can be extended:
- Page tracking: Add
currentPagefield to see which pages were viewed - Scroll depth: Track how far user scrolled on each page
- Interaction tracking: Only heartbeat if user has interacted recently
- Offline queue: Store heartbeats in localStorage if offline, sync later
- Analytics dashboard: Admin view showing session metrics
Summary
Session duration tracking doesn't need complex infrastructure. A simple heartbeat pattern with:
- Vanilla JavaScript
- Native browser APIs
- A single NocoDB table
...gives you reliable, accurate session duration data with minimal code and zero dependencies.
Time investment: ~15 minutes Lines of code: ~200 Dependencies added: 0 Reliability: High (visibility-aware, beacon fallback)
Sometimes the simplest solution is the best one.