javisantana.com

Landing page change log

Landing page change log

This log connects landing-page iterations with the events in events.duckdb. Localhost and 127.0.0.1 sessions are excluded from visitor baselines.

2026-07-26 — Shorter, article-first landing page

Baseline

Data available through 2026-07-26 (the latest /landing.html event is from 2026-07-25). The current Teenage Engineering design launched on 2026-07-13.

Hypothesis

Visitors—half of whom arrive from Twitter/X—should understand who Javi is in the first viewport and encounter a small, useful set of articles before newsletter promotion. Reducing choice and page length should increase attributable article visits and section completion.

Changes

Metrics to compare after deployment

Use only sessions after the deployment timestamp and exclude local referrers.

2026-07-27 — Reliable, versioned landing measurement

Deployment timestamp: pending. This iteration is not deployed yet.

Baseline

Data window: 2026-07-26 08:50 CEST through 2026-07-27 09:18 CEST, beginning with the first event after commit 5cba1e4. Localhost and 127.0.0.1 referrers are excluded.

Hypothesis

With the post-redesign sample too small for another visual change, preserving the current article-first page and making its measurement reliable will produce a valid comparison sooner than changing content again. Versioned tracker payloads, cache-busted delivery, and viewport-relative section thresholds should make the next session cohort distinguishable and its important interactions attributable.

Changes

Metrics to compare after deployment

Use sessions with analyticsVersion = '2026-07-27', starting at the confirmed deployment timestamp, and keep excluding local referrers.

2026-07-28 — Clean landing experiment boundary

Deployment timestamp: pending. This iteration is not deployed yet.

Baseline

Data window: 2026-07-27 10:36 CEST through 2026-07-28 18:34 CEST, covering the 15 non-local-referrer sessions carrying analyticsVersion = '2026-07-27'. The window begins with the first observed versioned event; it is not a confirmed deployment timestamp. Repeated batched events were deduplicated by session and event payload.

Hypothesis

The current content is already producing article visits, and 15 sessions are not enough evidence for another layout or copy change. Preventing local collection, recording only actionable link clicks, and emitting one reliable end event will create a clean post-change cohort without disrupting the article-first experience.

Changes

Metrics to compare after deployment

Use sessions with analyticsVersion = '2026-07-28', beginning at the confirmed deployment timestamp.

2026-07-31 — Direct article CTA and denser first viewport

Deployment timestamp: 2026-07-31 18:48 CEST (commit b4576fe).

Baseline

Data window: versioned non-local sessions with analyticsVersion in ('2026-07-27', '2026-07-28') through 2026-07-31 14:32 UTC (latest event in events.duckdb). Localhost and 127.0.0.1 referrers are excluded. Batched events were expanded and deduplicated by session, event fields, and second.

Hypothesis

Most visitors bounce before scrolling past the first block. The primary hero CTA only jumped to #start-here, which is often already on screen, so it could not create an article visit. Putting a direct link to the strongest featured article in the first viewport, tightening hero density so writing stays visible, and shortening the latest list from 9 to 5 should raise article CTR among short sessions without changing who Javi is or what the page is for.

Changes

Metrics to compare after deployment

Use sessions with analyticsVersion = '2026-07-31', beginning at the confirmed deployment timestamp; exclude local referrers.

2026-08-03 — Name-first hero and active duration

Deployment timestamp: 2026-08-03 17:31 CEST (this commit).

Baseline

Data window: non-local sessions with analyticsVersion = '2026-07-31' from 2026-07-31 18:51 CEST through 2026-08-03 14:53 CEST (latest event in events.duckdb at analysis time). Localhost and 127.0.0.1 referrers excluded. Batched events expanded and deduplicated by session, fields, and second. Wall-clock durations above 1 hour treated as open-tab outliers for medians.

Hypothesis

The direct article CTA is working, especially on short Twitter sessions, but the most-clicked control is still hero-about: visitors arrive needing a clear name and role before they commit to a post. Putting the name in the H1 and a compact who/what line in the first viewport—while keeping the article CTA and making its label name the destination—should raise article CTR without another layout rewrite. Recording visibility-aware activeDuration will make the next cohort’s engagement medians trustworthy.

Changes

Metrics to compare after deployment

Use sessions with analyticsVersion = '2026-08-03', beginning at the confirmed deployment timestamp; exclude local referrers. Prefer activeDuration for engagement medians; keep wall-clock duration only for outlier checks.

2026-08-06 — Compact “start here” rows

Not deployed at time of writing. Deployment timestamp: pending.

Baseline

Data window: non-local sessions with analyticsVersion = '2026-08-03', from 2026-08-03 15:33 UTC through 2026-08-06 14:01 UTC (latest event in events.duckdb at analysis time). Localhost and 127.0.0.1 referrers excluded. n=15 sessions — small; treat everything below as directional.

Hypothesis

Attention, not persuasion, is the binding constraint: the median visitor gives the page about six active seconds, and only a quarter of them ever reach the about section. Within that budget the three-card featured grid is expensive — it costs a full scroll of space and, pooled over 71 sessions, converts no better than a compact list of titles. Rendering “start here” in the same lightweight row form as the latest list should put both sets of articles inside the first six seconds of scrolling, raise total article CTR, and lift entry into the lower sections, without removing any link or changing the visual language.

Changes

Validation notes

Verified with real device-metric emulation over CDP rather than chrome --headless --window-size: that flag does not set the layout width (it renders at 500px and crops), which means the *-mobile-320.png shots archived in earlier iterations do not show true 320px layout. At genuine 320px and 390px viewports document.scrollWidth == clientWidth, so the page has no horizontal overflow, and all rows wrap correctly. Shots archived as 2026-08-06-1900-compact-start-here-{desktop,mobile-320}.png.

Metrics to compare after deployment

Use sessions with analyticsVersion = '2026-08-06' from the confirmed deployment timestamp; exclude local referrers. Compare against the 2026-08-03 cohort, which shares the corrected depth instrumentation.

2026-08-10 — “What brought you here?” intent micro-survey

Not deployed at time of writing. Deployment timestamp: pending.

Baseline and rationale

Data window: /landing.html sessions grouped by sessionId over the 14 days through 2026-08-09, localhost/127.0.0.1 excluded. ~1–18 sessions/day, ~100 total. That volume is far too small for a measured A/B split — two variants would take months to separate — so this iteration does not attempt a powered experiment.

Instead it captures stated intent, which the dataset has never had. All prior iterations tuned layout from behavior (scroll, clicks) but we don’t know why people arrive. A one-tap qualitative survey needs no statistical power to be useful: even a handful of answers tell us which audience the page actually serves.

Change

Validation

Built with npm run build; generated a/landing.html contains the section, all five chips, the inline script, and the scoped CSS. Inline script passes node --check. Bucketing verified deterministic and ~49.9% exposed over 20k synthetic ids. git diff --check clean. Full-page screenshots not yet archived (pending the deploy, per skill).

Metrics to compare after deployment

Filter to action = 'landing_feedback', group by sessionId, from the confirmed deployment timestamp; exclude local referrers.

2026-08-11 — Drop Tinybird; rewire survey to self-hosted analytics

Deployed: pending.

Why

Checked the survey results in events.duckdb and found zero landing_feedback rows — not because nobody answered, but because the survey was emitting into the wrong pipeline. The page ran two trackers: the self-hosted assets/js/tracking.js (→ e.javisantana.com, the only source of events.duckdb, which is scp’d from the server) and the Tinybird web-analytics snippet in _includes/tracker.html (→ api.tinybird.co, datasource analytics_events, exposed as window.Tinybird.trackEvent). The survey called window.Tinybird.trackEvent, so its events went to Tinybird — never into events.duckdb. No stored row has ever carried an action key, confirming that pipeline never reached this DB. The generic click capture in tracking.js only records clicks inside <a> anchors, and the chips are <button>, so there was no fallback signal either.

Decision: stop sending any data to Tinybird and route the survey through the self-hosted pipeline instead.

Change

Corrected query path (supersedes the prior entry)

Survey responses now land in events.duckdb as batched events with a top-level type, alongside all other analytics — not under action. Query:

Validation

bundle exec jekyll build clean. node --check assets/js/tracking.js OK; landing inline script parses via new Function. Built a/landing.html references jsAnalytics and no longer references window.Tinybird; a/unsubscribe.html no longer references api.tinybird.co. Not yet deployed — no responses to report until it is live and exposed.

2026-08-12 — Filter non-human traffic from landing measurement

Not deployed at time of writing. Deployment timestamp: pending.

Baseline

Data window: non-local sessions on /landing.html with analyticsVersion = '2026-08-06' (the current live cohort, first seen 2026-08-10 08:20 UTC) through 2026-08-12 06:02 UTC, the latest event in events.duckdb. Localhost and 127.0.0.1 referrers excluded. n = 60 landing sessions. Treat everything below as directional given the sample.

The headline engagement numbers looked like a catastrophic regression — median activeDuration 2s, median max scroll 0% — but that is a measurement artifact, not visitor behaviour. Segmenting by acquisition source shows the baseline is dominated by non-human traffic:

So ~2/3 of recorded landing sessions are automated and were dragging every pooled median toward zero. Section-entry rates were also inflated: start-here 57/60 and latest-writing 55/60 fire on load for bots that never scroll.

Content signal, restricted to the clean traffic and pooled across all five versioned cohorts (click-sessions by group), is consistent with prior entries and worth acting on once measurement is trustworthy — but not before:

Hypothesis

No content or layout experiment can be judged while two-thirds of the sample is automated: the bots have no referrer, no scroll, and no clicks, so they crush engagement medians and dilute every click-through rate. The principle is never let non-humans into the metrics that describe humans — which is achieved by segmenting them out in analysis, not by deleting data. Doing so surfaces the real audience’s behaviour (Twitter: 10s, 50% scroll, 56% article CTR) and makes the next content iteration (revisit the underperforming featured set / dead Google slot) legible instead of noise.

Changes

Survey status at analysis time (2026-08-06 cohort)

The rewired survey (live ~2026-08-11 08:55 UTC) produced 1 landing_feedback response (choice=startups) and 0 survey_shown events — no response-rate denominator — because of the load-order bug above. Combined with a ~1-day window and small eligible traffic (~24 human landing sessions since 2026-08-11, ~half exposed at EXPOSURE_PCT=50), the survey has effectively no measurable result yet. The fix above is a precondition for reading it at all; consider raising EXPOSURE_PCT toward 100 given the low volume.

Bots: nothing to fix in collection — exclude at query time

The tracker already records everything needed to spot non-human traffic (ip, ua, referrer, scroll, duration, clicks). So collection stays as-is — record all of it, raw — and bot exclusion is a per-analysis decision, not a baked-in rule.

For this cohort it isn’t even a heuristic: the ~2/3 pollution is 37 sessions from a single IP (88.19.35.x) — identical UA, empty referrer, every one at 0s active / 0% scroll / no clicks. Excluding that IP restores the real picture. The general query-time move is the same: drop any single IP producing many single-visit, zero-interaction sessions. Always report raw vs filtered side by side.

Validation

bundle exec jekyll build clean (pre-existing unrelated Liquid/doc/cv.html warnings left untouched). node --check assets/js/tracking.js OK; the built survey inline script (with the load-order retry) passes node --check. git diff assets/js/tracking.js is version-only (ANALYTICS_VERSION 2026-08-062026-08-12) — the bot guard was reverted. Built a/landing.html and a/index.html reference ?v=20260812; featured internal links present in the build. git diff --check clean on all changed files. Desktop screenshot archived as landing_history_shots/2026-08-12-bot-filter-measurement-desktop.png (content visually identical to the 2026-08-06 iteration — this iteration changes no visible content).

Metrics to compare after deployment

Use sessions with analyticsVersion = '2026-08-12' from the confirmed deployment timestamp; exclude local referrers.

2026-08-14 — Survey at full exposure (collect the “why are you here” signal)

Deployment: commit 518e2f9, pushed to gh-pages 2026-08-14T09:18:10+02:00 (07:18 UTC). Cohort boundary: analyticsVersion = '2026-08-14' (tracker cache-buster ?v=20260814) — this version tag, not the wall-clock time, is the exact pre/post separator.

Baseline

Data through 2026-08-14 (latest event 2026-08-14T01:48 UTC). Landing = /landing.html (note / is now the archive, not the landing). Content-identical cohorts 2026-08-06 + 2026-08-12 pooled since the 2026-08-06 content shipped (the 2026-08-12 change was measurement-only). One direct-load IP 88.19.35.18044 sessions, all NULL referrer, single-visit / zero interaction — excluded from human metrics (same cluster flagged 2026-08-11/12).

Human-filtered vs raw, side by side:

segment sessions median active median scroll click-through
human 32 8s 46% 72% (23/32)
raw (incl. bot IP) 72 2s 0% 33%

Hypothesis

The measurement plumbing is fixed but the exposure dial, not the plumbing, is now the bottleneck: at 50% of ~15 human landing sessions/week the survey will never accumulate a readable “what brought you here?” distribution. This site’s traffic is too low to power an A/B split, so there is nothing to protect by withholding the survey from half of visitors — and the CTA it replaces in the hero (hero-read-four-years, 1 click, duplicated as featured-four-years right below) is the page’s weakest reading entry point, so the swap costs almost nothing. Raising exposure to 100% maximizes the qualitative signal — which audience actually lands here (startups vs data-eng vs product vs “curious about Javi”) — that should drive the next iteration on the underperforming featured set. Content is held unchanged so that signal is clean and attributable to the exposure change alone.

Changes

Validation

bundle exec jekyll build clean (pre-existing unrelated Liquid / doc/cv.html warnings left untouched). Built a/landing.html shows EXPOSURE_PCT = 100 and tracking.js?v=20260814; a/index.html also references ?v=20260814. node --check assets/js/tracking.js OK and the built inline survey script parses. All three featured internal links present in the build. git diff --check clean; source diff is exactly three one-line changes (landing.md, assets/js/tracking.js, _includes/tracker.html) — a/ build output is gitignored and rebuilt by CI. Desktop screenshot archived as landing_history_shots/2026-08-14-survey-full-exposure-desktop.png, captured from the local build with the analytics host (e.javisantana.com) blackholed so capture does not pollute events.duckdb; it shows the survey visible in the hero (the 100%-exposure state).

Metrics to compare after deployment

Use sessions with analyticsVersion = '2026-08-14' from the confirmed deployment timestamp; exclude local referrers and the 88.19.35.180 bot IP; report raw and human-filtered side by side.