Skip to content

Commit 851365c

Browse files
committed
fix(analytics): move scroll tracking to separate TypeScript file
Problem: MDX parser couldn't handle object literals inside <script> tags (acorn parser threw "Unexpected content after expression" error) Solution: Extract scroll depth tracking to standalone TypeScript file - Create src/scripts/track-scroll-depth.ts with full implementation - Replace inline script with simple import in statsbomb.mdx - Maintains all functionality (Intersection Observer, event firing, etc.) Why this works: Astro processes .ts files normally, but MDX has special parsing rules for <script> tags that conflict with curly braces in objects. No behavior change - same tracking logic, just better organized.
1 parent 4be1e47 commit 851365c

File tree

3 files changed

+137
-87
lines changed

3 files changed

+137
-87
lines changed

docs/plans/2025-11-01-umami-analytics-integration.md

Lines changed: 72 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
## Problem
1010

1111
After launching the portfolio and sharing on LinkedIn, we have zero visibility into:
12+
1213
- **Visitor behavior** - Are people reading the case study? Where do they drop off?
1314
- **Traffic sources** - Is LinkedIn driving traffic? Which posts work?
1415
- **Engagement patterns** - Do CTAs work? Which sections engage?
@@ -23,6 +24,7 @@ After launching the portfolio and sharing on LinkedIn, we have zero visibility i
2324
**Chosen solution:** Umami Cloud analytics with custom event tracking
2425

2526
**Why Umami:**
27+
2628
-**Free tier sufficient** (100K events/month, 3 websites, 6-month retention)
2729
-**Privacy-first** (cookie-free, GDPR compliant, no consent banner)
2830
-**Custom events** (scroll depth, CTA clicks, section engagement)
@@ -31,6 +33,7 @@ After launching the portfolio and sharing on LinkedIn, we have zero visibility i
3133
-**Lightweight** (~3KB script, async load, no performance impact)
3234

3335
**Alternative considered (70% confident):** Cloudflare Web Analytics
36+
3437
- **Pros:** 100% free forever, even simpler setup
3538
- **Cons:** No custom event tracking (can't measure scroll depth, CTA clicks)
3639
- **Why rejected:** User wants "nice to have" event tracking for engagement insights
@@ -84,24 +87,26 @@ After launching the portfolio and sharing on LinkedIn, we have zero visibility i
8487
### Tracking Strategy
8588

8689
**Automatic tracking (no code needed):**
90+
8791
- Pageviews, unique visitors
8892
- Referrer sources (linkedin.com visible)
8993
- Country, device, browser, OS
9094
- Page paths and durations
9195

9296
**Custom event tracking:**
9397

94-
| Event Type | Trigger | Data Captured | Why |
95-
|------------|---------|---------------|-----|
96-
| `scroll-depth` | Intersection Observer at 25/50/75/100% | page, depth, section | Shows where readers drop off in case study |
97-
| `cta-click` | Button/Link click | location, text, destination | Measures CTA effectiveness |
98-
| `section-view` | Intersection Observer on major sections | page, section-name | Identifies engaging vs skipped sections |
99-
| `outbound-link` | External link click | destination, source-page | Tracks LinkedIn/GitHub profile clicks |
98+
| Event Type | Trigger | Data Captured | Why |
99+
| --------------- | --------------------------------------- | --------------------------- | ------------------------------------------ |
100+
| `scroll-depth` | Intersection Observer at 25/50/75/100% | page, depth, section | Shows where readers drop off in case study |
101+
| `cta-click` | Button/Link click | location, text, destination | Measures CTA effectiveness |
102+
| `section-view` | Intersection Observer on major sections | page, section-name | Identifies engaging vs skipped sections |
103+
| `outbound-link` | External link click | destination, source-page | Tracks LinkedIn/GitHub profile clicks |
100104

101105
**UTM parameters for LinkedIn:**
106+
102107
```
103-
https://saad-shahd.dev/?utm_source=linkedin&utm_medium=social&utm_campaign=launch-2025-01
104-
https://saad-shahd.dev/portfolio/statsbomb/?utm_source=linkedin&utm_medium=social&utm_campaign=case-study-statsbomb
108+
https://saadshahd.github.io/?utm_source=linkedin&utm_medium=social&utm_campaign=launch-2025-01
109+
https://saadshahd.github.io/portfolio/statsbomb/?utm_source=linkedin&utm_medium=social&utm_campaign=case-study-statsbomb
105110
```
106111

107112
---
@@ -111,16 +116,19 @@ https://saad-shahd.dev/portfolio/statsbomb/?utm_source=linkedin&utm_medium=socia
111116
### Phase 1: Base Integration (5 minutes, 1 story point)
112117

113118
**Files modified:**
119+
114120
1. `src/layouts/Layout.astro` - Add Umami script tag
115121
2. `.env` - Add `PUBLIC_UMAMI_WEBSITE_ID` (from Umami dashboard)
116122
3. `.env.example` - Document environment variable
117123

118124
**Changes:**
125+
119126
- Add conditional script tag in `<head>` (only loads if env var set)
120127
- Async/defer loading (no performance impact)
121128
- TypeScript globals for `window.umami`
122129

123130
**Verification:**
131+
124132
- Load site, check Network tab for `script.js` from `cloud.umami.is`
125133
- Visit Umami dashboard, confirm pageview recorded
126134
- Test on multiple pages, verify paths tracked correctly
@@ -130,22 +138,25 @@ https://saad-shahd.dev/portfolio/statsbomb/?utm_source=linkedin&utm_medium=socia
130138
### Phase 2: Analytics Utility (10 minutes, 1 story point)
131139

132140
**Files created:**
141+
133142
1. `src/utils/analytics.ts` - Event tracking helpers
134143
2. `src/types/umami.d.ts` - TypeScript definitions for Umami API
135144

136145
**API design:**
146+
137147
```typescript
138148
// Core tracking function
139-
export function trackEvent(name: string, data?: Record<string, any>): void
149+
export function trackEvent(name: string, data?: Record<string, any>): void;
140150

141151
// Convenience helpers
142-
export function trackCTAClick(location: string, text: string): void
143-
export function trackScrollDepth(page: string, depth: number): void
144-
export function trackSectionView(page: string, section: string): void
145-
export function trackOutboundLink(destination: string, source: string): void
152+
export function trackCTAClick(location: string, text: string): void;
153+
export function trackScrollDepth(page: string, depth: number): void;
154+
export function trackSectionView(page: string, section: string): void;
155+
export function trackOutboundLink(destination: string, source: string): void;
146156
```
147157

148158
**Type safety:**
159+
149160
```typescript
150161
// src/types/umami.d.ts
151162
interface Window {
@@ -156,6 +167,7 @@ interface Window {
156167
```
157168

158169
**Error handling:**
170+
159171
- Check `typeof window !== 'undefined'` (SSR safety)
160172
- Check `'umami' in window` (script loaded)
161173
- Silent failure if Umami unavailable (graceful degradation)
@@ -165,11 +177,13 @@ interface Window {
165177
### Phase 3: CTA Tracking (10 minutes, 1 story point)
166178

167179
**Files modified:**
180+
168181
1. `src/components/Button.astro` - Add click tracking
169182
2. `src/components/Link.astro` - Add outbound link tracking
170183
3. `src/components/CalloutCTA.astro` - Track CTA interactions
171184

172185
**Implementation pattern:**
186+
173187
```astro
174188
---
175189
// Button.astro
@@ -197,6 +211,7 @@ const isExternal = href.startsWith('http');
197211
```
198212

199213
**Tracked CTAs:**
214+
200215
- Homepage: "Explore My Work", "Start a Conversation"
201216
- Case study: "Read Full Case Study" cards
202217
- About page: "Download Resume"
@@ -207,53 +222,60 @@ const isExternal = href.startsWith('http');
207222
### Phase 4: Scroll Depth Tracking (15 minutes, 2 story points)
208223

209224
**Files modified:**
225+
210226
1. `src/pages/portfolio/statsbomb.astro` - Add scroll observer
211227

212228
**Implementation approach:**
229+
213230
```typescript
214231
// Intersection Observer watching key sections
215232
const observeScrollDepth = () => {
216233
const sections = [
217-
{ element: document.querySelector('#tldr'), depth: 25 },
218-
{ element: document.querySelector('#architecture'), depth: 50 },
219-
{ element: document.querySelector('#impact'), depth: 75 },
220-
{ element: document.querySelector('footer'), depth: 100 },
234+
{ element: document.querySelector("#tldr"), depth: 25 },
235+
{ element: document.querySelector("#architecture"), depth: 50 },
236+
{ element: document.querySelector("#impact"), depth: 75 },
237+
{ element: document.querySelector("footer"), depth: 100 },
221238
];
222239

223-
const observer = new IntersectionObserver((entries) => {
224-
entries.forEach(entry => {
225-
if (entry.isIntersecting) {
226-
const section = sections.find(s => s.element === entry.target);
227-
if (section) {
228-
umami.track('scroll-depth', {
229-
page: 'statsbomb-case-study',
230-
depth: `${section.depth}%`,
231-
section: entry.target.id
232-
});
233-
observer.unobserve(entry.target); // Fire once per section
240+
const observer = new IntersectionObserver(
241+
(entries) => {
242+
entries.forEach((entry) => {
243+
if (entry.isIntersecting) {
244+
const section = sections.find((s) => s.element === entry.target);
245+
if (section) {
246+
umami.track("scroll-depth", {
247+
page: "statsbomb-case-study",
248+
depth: `${section.depth}%`,
249+
section: entry.target.id,
250+
});
251+
observer.unobserve(entry.target); // Fire once per section
252+
}
234253
}
235-
}
236-
});
237-
}, { threshold: 0.5 }); // Fire when 50% of section visible
254+
});
255+
},
256+
{ threshold: 0.5 }
257+
); // Fire when 50% of section visible
238258

239-
sections.forEach(s => s.element && observer.observe(s.element));
259+
sections.forEach((s) => s.element && observer.observe(s.element));
240260
};
241261

242262
// Run after page load
243-
if (document.readyState === 'complete') {
263+
if (document.readyState === "complete") {
244264
observeScrollDepth();
245265
} else {
246-
window.addEventListener('load', observeScrollDepth);
266+
window.addEventListener("load", observeScrollDepth);
247267
}
248268
```
249269

250270
**Why Intersection Observer:**
271+
251272
- More accurate than scroll listeners
252273
- Better performance (browser-optimized)
253274
- Fires once per section (no duplicate events)
254275
- Respects `prefers-reduced-motion` (standard behavior)
255276

256277
**Sections tracked:**
278+
257279
1. **25% (TL;DR)** - User started reading
258280
2. **50% (Architecture)** - User engaged with technical content
259281
3. **75% (Impact)** - User reading outcomes
@@ -266,12 +288,14 @@ if (document.readyState === 'complete') {
266288
**Umami dashboard setup:**
267289

268290
1. **Custom reports:**
291+
269292
- "LinkedIn Traffic" - Filter by `utm_source=linkedin`
270293
- "Case Study Engagement" - Filter `scroll-depth` events on statsbomb page
271294
- "CTA Performance" - Filter `cta-click` events, group by text
272295
- "Drop-off Analysis" - Compare scroll depths (100% - 75% - 50% - 25%)
273296

274297
2. **Goals (if available on free tier):**
298+
275299
- Goal: "Contact CTA Click"
276300
- Goal: "Full Case Study Read" (100% scroll depth)
277301

@@ -285,6 +309,7 @@ if (document.readyState === 'complete') {
285309
### Why Not Google Analytics?
286310

287311
**Rejected (40% confident GA would work):**
312+
288313
- ❌ Cookie-based (requires consent banner)
289314
- ❌ Privacy concerns (tracks users across sites)
290315
- ❌ Overkill for portfolio (enterprise features unused)
@@ -294,6 +319,7 @@ if (document.readyState === 'complete') {
294319
### Why Not Plausible? ($9/month)
295320

296321
**Deferred (90% confident Plausible would be better, but cost matters):**
322+
297323
- ❌ $9/month exceeds "free only" budget
298324
- ✅ Automatic scroll depth tracking (would save implementation time)
299325
- ✅ Better UX, Google Search Console integration
@@ -302,6 +328,7 @@ if (document.readyState === 'complete') {
302328
### Environment Variable Strategy
303329

304330
**Decision:** Use `PUBLIC_UMAMI_WEBSITE_ID` (public prefix)
331+
305332
- ✅ Astro convention for client-side env vars
306333
- ✅ Safe to expose (website ID is public in script tag anyway)
307334
- ✅ Easy to set in GitHub Pages environment (if needed)
@@ -311,18 +338,21 @@ if (document.readyState === 'complete') {
311338
## Privacy & Compliance
312339

313340
**GDPR/CCPA compliance:**
341+
314342
- ✅ No cookies (no consent banner required)
315343
- ✅ No personal data collected (anonymous counts)
316344
- ✅ No cross-site tracking
317345
- ✅ Visitor IP addresses anonymized
318346

319347
**Performance impact:**
348+
320349
- Umami script: ~3KB gzipped
321350
- Loads async (doesn't block render)
322351
- Custom events: negligible (<1KB per page)
323352
- **Expected load time:** 2.1s → 2.1s (no change)
324353

325354
**Accessibility:**
355+
326356
- ✅ Tracking doesn't affect screen readers
327357
- ✅ No visual changes to UI
328358
- ✅ Works with JavaScript disabled (graceful degradation)
@@ -332,13 +362,15 @@ if (document.readyState === 'complete') {
332362
## Success Metrics
333363

334364
**After 1 week, we should know:**
365+
335366
- ✅ Total visitors from LinkedIn post
336367
- ✅ Bounce rate (% who leave immediately)
337368
- ✅ Case study engagement (% reaching 50%, 75%, 100% depth)
338369
- ✅ CTA click-through rate
339370
- ✅ Top referrer sources
340371

341372
**After 1 month, we can optimize:**
373+
342374
- If 50% drop at 50% scroll → Middle sections need work
343375
- If LinkedIn bounce rate >70% → Landing page needs improvement
344376
- If CTA clicks <5% → CTA copy/placement needs adjustment
@@ -349,12 +381,14 @@ if (document.readyState === 'complete') {
349381
## Reversibility
350382

351383
**High reversibility (90% confident):**
384+
352385
- Remove script tag from Layout.astro → Back to no tracking (5 minutes)
353386
- Keep event tracking code → Works with other analytics (Plausible, Fathom)
354387
- Export data from Umami → CSV download anytime
355388
- Migrate to self-hosted Umami → Same tracking code, different endpoint
356389

357390
**Migration path to paid solution:**
391+
358392
1. Keep Umami script tag
359393
2. Add Plausible script tag (runs in parallel)
360394
3. Compare data for 1-2 weeks
@@ -413,6 +447,7 @@ Phase 6: Documentation & Launch (0.5 story points)
413447
## Future Enhancements (Not Now)
414448

415449
**Consider later if traffic grows:**
450+
416451
1. **Heatmaps** (requires paid service like Hotjar)
417452
2. **Session replay** (Umami Pro tier $20/month or LogRocket)
418453
3. **A/B testing** (requires custom implementation or tool)
@@ -444,19 +479,22 @@ Phase 6: Documentation & Launch (0.5 story points)
444479
**Overall confidence:** 85%
445480

446481
**Based on:**
482+
447483
- Umami is proven solution (31K GitHub stars, used by thousands)
448484
- Free tier covers expected traffic (100K events >> portfolio needs)
449485
- Implementation is straightforward (well-documented API)
450486
- Reversibility is high (remove script tag = done)
451487

452488
**Key assumption that could invalidate this:**
489+
453490
- If traffic exceeds 100K events/month → Upgrade to Pro ($20/month) or self-host
454491

455492
**Alternative confidence:** Self-hosted Umami (75% confident) - More setup complexity but free forever
456493

457494
---
458495

459496
**Applied patterns:**
497+
460498
- Library-First (using Umami vs building custom analytics)
461499
- Progressive Disclosure (start with base tracking, add events incrementally)
462500
- Make Illegal States Unrepresentable (TypeScript types prevent invalid event data)

0 commit comments

Comments
 (0)