Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion sentry.client.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import * as Sentry from "@sentry/astro";
import { scrubEvent, scrubBreadcrumb } from "./src/lib/sentryScrub.js";

Sentry.init({
dsn: "https://bf8afd06f49d13d2e136c99d78e88626@o4511568346021888.ingest.de.sentry.io/4511568347856976",
// To disable sending user data, uncomment the line below. For more info visit:
// https://docs.sentry.io/platforms/javascript/guides/astro/configuration/options/#dataCollection
dataCollection: { userInfo: false },
});
beforeSend: scrubEvent,
beforeBreadcrumb: scrubBreadcrumb,
});
2 changes: 2 additions & 0 deletions src/app/aboutDrawer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { el } from './dom';
import { initAnalyticsOptOut } from '../lib/analyticsOptOut.js';

const drawer = () => el('about-drawer');

Expand Down Expand Up @@ -50,4 +51,5 @@ export function initAboutDrawer() {
el('about-overlay').addEventListener('click', dismissAbout);
document.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Escape') dismissAbout(); });
document.addEventListener('keydown', trapAboutFocus);
initAnalyticsOptOut();
}
69 changes: 69 additions & 0 deletions src/components/AnalyticsOptOut.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
import { translations, type Lang } from '../lib/i18n';

interface Props {
lang: Lang;
}

const { lang } = Astro.props;
const t = translations[lang];
---
<!-- Wired by initAnalyticsOptOut() (lib/analyticsOptOut.js). -->
<label class="optout">
<span class="optout-text">
<span class="optout-label">{t.optout_label}</span>
<span class="optout-hint">{t.optout_hint}</span>
</span>
<input type="checkbox" role="switch" class="optout-switch" checked />
</label>

<style>
.optout {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
cursor: pointer;
}
.optout-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.optout-label {
font-size: 0.85rem;
font-weight: 600;
color: #d8dbc8;
}
.optout-hint {
font-size: 0.75rem;
line-height: 1.4;
color: rgba(216,219,200,0.6);
}
.optout-switch {
appearance: none;
flex: 0 0 auto;
position: relative;
width: 38px;
height: 22px;
margin: 0;
border-radius: 11px;
background: rgba(216,219,200,0.2);
cursor: pointer;
transition: background 0.2s;
}
.optout-switch::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #d8dbc8;
transition: transform 0.2s;
}
.optout-switch:checked { background: #f0f2a0; }
.optout-switch:checked::after { transform: translateX(16px); background: #0e120a; }
.optout-switch:focus-visible { outline: 2px solid #f0f2a0; outline-offset: 2px; }
</style>
220 changes: 159 additions & 61 deletions src/components/PrivacyContent.astro

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/components/app/AboutDrawer.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import { translations, LANGS, langPath, type Lang } from '../../lib/i18n';
import AboutContent from '../AboutContent.astro';
import AnalyticsOptOut from '../AnalyticsOptOut.astro';
import githubIcon from '../../icons/github.svg?raw';

interface Props {
Expand Down Expand Up @@ -31,6 +32,9 @@ const t = translations[lang];
GitHub
</a>
</div>
<div id="about-optout">
<AnalyticsOptOut lang={lang} />
</div>
<div class="footer-links">
<a href={langPath(lang, 'about')} class="footer-link">{t.aria_about}</a>
<span class="footer-link">-</span>
Expand Down
2 changes: 1 addition & 1 deletion src/components/app/AppHead.astro
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ const t = translations[lang];
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "CHF" },
"author": { "@type": "Person", "url": "https://github.com/paultursuru" }
})} />
<script is:inline defer src="https://cloud.umami.is/script.js" data-website-id="3ed0ad98-8924-4e14-aa5b-6afe5d3dc7ac" data-domains="meridian-way.ch" data-auto-pageview="false" data-performance="false"></script>
<script is:inline defer src="https://cloud.umami.is/script.js" data-website-id="3ed0ad98-8924-4e14-aa5b-6afe5d3dc7ac" data-domains="meridian-way.ch" data-auto-pageview="false" data-performance="false" data-before-send="umamiBeforeSend"></script>
2 changes: 1 addition & 1 deletion src/layouts/AboutLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const pageUrl = (l: Lang) => langUrl(l, 'about/');
<meta property="og:title" content={t.about_title} />
<meta property="og:description" content={t.about_meta_description} />
<meta property="og:image" content="https://meridian-way.ch/meridian-way-og.png" />
<script is:inline defer src="https://cloud.umami.is/script.js" data-website-id="3ed0ad98-8924-4e14-aa5b-6afe5d3dc7ac" data-domains="meridian-way.ch" data-auto-pageview="false" data-performance="false"></script>
<script is:inline defer src="https://cloud.umami.is/script.js" data-website-id="3ed0ad98-8924-4e14-aa5b-6afe5d3dc7ac" data-domains="meridian-way.ch" data-auto-pageview="false" data-performance="false" data-before-send="umamiBeforeSend"></script>
</head>
<body class="about-page">

Expand Down
4 changes: 3 additions & 1 deletion src/layouts/PrivacyLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const backLabels: Record<Lang, string> = {
<link rel="canonical" href={pageUrl(lang)} />
{LANGS.map(l => <link rel="alternate" hreflang={l} href={pageUrl(l)} />)}
<link rel="alternate" hreflang="x-default" href={pageUrl('fr')} />
<script is:inline defer src="https://cloud.umami.is/script.js" data-website-id="3ed0ad98-8924-4e14-aa5b-6afe5d3dc7ac" data-domains="meridian-way.ch" data-auto-pageview="false" data-performance="false"></script>
<script is:inline defer src="https://cloud.umami.is/script.js" data-website-id="3ed0ad98-8924-4e14-aa5b-6afe5d3dc7ac" data-domains="meridian-way.ch" data-auto-pageview="false" data-performance="false" data-before-send="umamiBeforeSend"></script>
</head>
<body class="privacy-page">

Expand Down Expand Up @@ -131,6 +131,8 @@ const backLabels: Record<Lang, string> = {
<!-- Bundle par Astro (pas is:inline) : la CSP interdit les scripts inline. -->
<script>
import { trackPageview } from '../lib/analytics.js';
import { initAnalyticsOptOut } from '../lib/analyticsOptOut.js';
initAnalyticsOptOut();
trackPageview();
</script>

Expand Down
33 changes: 33 additions & 0 deletions src/lib/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,39 @@
const RETRY_MS = 200;
const MAX_WAIT_MS = 5000;

// Umami keeps where routes are searched, not who searched them: route points
// rounded to ~100 m (3 decimals), and the typed labels, which can be a home
// address, dropped. Applied to the page URL and the referrer of every send.
const DROPPED = ['fromq', 'toq'];
const POINTS = ['from', 'to'];
const LAT_LNG = /^-?\d+(\.\d+)?,-?\d+(\.\d+)?$/;

export function coarsenUrl(url) {
if (typeof url !== 'string') return url;
const q = url.indexOf('?');
if (q < 0) return url;
const h = url.indexOf('#', q);
const hash = h < 0 ? '' : url.slice(h);
const params = new URLSearchParams(url.slice(q + 1, h < 0 ? undefined : h));
if (![...DROPPED, ...POINTS].some((k) => params.has(k))) return url;
for (const k of DROPPED) params.delete(k);
for (const k of POINTS) {
const v = params.get(k);
if (v && LAT_LNG.test(v)) params.set(k, v.split(',').map((n) => Number(n).toFixed(3)).join(','));
}
const query = params.toString();
return url.slice(0, q) + (query ? `?${query}` : '') + hash;
}

export function umamiBeforeSend(type, payload) {
if (!payload) return payload;
return { ...payload, url: coarsenUrl(payload.url), referrer: coarsenUrl(payload.referrer) };
}

// Named by data-before-send on the Umami tags; read at each send. Every page
// sends its first payload through trackPageview(), after this has run.
if (typeof window !== 'undefined') window.umamiBeforeSend = umamiBeforeSend;

// Umami's global is absent until its script loads, and for good behind a
// blocker: every custom event goes through here.
export function track(...args) {
Expand Down
35 changes: 35 additions & 0 deletions src/lib/analyticsOptOut.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Umami's tracker reads this key before every send: any non-empty value stops
// page views and custom events alike, with no reload. Removing it resumes.
const KEY = 'umami.disabled';

export function isOptedOut() {
try {
return !!localStorage.getItem(KEY);
} catch {
return false;
}
}

// False when storage refuses the write (private browsing, full quota).
export function setOptedOut(out) {
try {
if (out) localStorage.setItem(KEY, '1');
else localStorage.removeItem(KEY);
return true;
} catch {
return false;
}
}

// Checked means "counted". Every switch is redrawn from storage after a change,
// so one that couldn't be saved snaps back instead of lying.
// No analytics event here: measuring who opts out would break the promise.
export function initAnalyticsOptOut() {
const switches = document.querySelectorAll('.optout-switch');
const sync = () => switches.forEach((s) => { s.checked = !isOptedOut(); });
sync();
switches.forEach((s) => s.addEventListener('change', () => {
setOptedOut(!s.checked);
sync();
}));
}
2 changes: 1 addition & 1 deletion src/lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const TECHNICAL = {
};

// Codes whose message is already the sentence: shown as-is.
const READY = new Set(['ADDRESS_NOT_FOUND', 'POSITION_UNKNOWN', 'NO_ROUTE']);
export const READY = new Set(['ADDRESS_NOT_FOUND', 'POSITION_UNKNOWN', 'NO_ROUTE']);

// The toast for a failed search. The technical detail stays on the error for
// the console and Sentry.
Expand Down
10 changes: 10 additions & 0 deletions src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ export const translations = {
error_geo_denied: 'Localisation refusée par le navigateur.',
error_geo_timeout: 'Localisation impossible, réessayez.',
about_privacy: 'Confidentialité',
optout_label: 'Statistiques de visite',
optout_hint: 'Sans cookie. Désactivez pour que cet appareil ne soit plus compté.',
share_btn_title: 'Partager cet itinéraire',
toast_link_copied: 'Lien copié dans le presse-papier.',
install_banner_android: '📲 Installez MeridianWay sur votre écran d\'accueil pour un accès plus rapide.',
Expand Down Expand Up @@ -218,6 +220,8 @@ export const translations = {
error_geo_denied: 'Standortzugriff wurde verweigert.',
error_geo_timeout: 'Standort nicht verfügbar, erneut versuchen.',
about_privacy: 'Datenschutz',
optout_label: 'Besuchsstatistik',
optout_hint: 'Ohne Cookie. Deaktivieren Sie diese Option, wenn Sie nicht möchten, dass dieses Gerät gezählt wird.',
share_btn_title: 'Route teilen',
toast_link_copied: 'Link in die Zwischenablage kopiert.',
install_banner_android: '📲 MeridianWay auf dem Startbildschirm installieren für schnelleren Zugriff.',
Expand Down Expand Up @@ -323,6 +327,8 @@ export const translations = {
error_geo_denied: 'Localizzazione rifiutata dal browser.',
error_geo_timeout: 'Localizzazione impossibile, riprova.',
about_privacy: 'Privacy',
optout_label: 'Statistiche di visita',
optout_hint: 'Senza cookie. Disattiva perché questo dispositivo non venga più contato.',
share_btn_title: 'Condividi il percorso',
toast_link_copied: 'Link copiato negli appunti.',
install_banner_android: '📲 Installa MeridianWay sulla schermata Home per un accesso più rapido.',
Expand Down Expand Up @@ -428,6 +434,8 @@ export const translations = {
error_geo_denied: 'Location access denied by browser.',
error_geo_timeout: 'Location unavailable, please try again.',
about_privacy: 'Privacy',
optout_label: 'Visit statistics',
optout_hint: 'No cookies. Turn off to stop counting this device.',
share_btn_title: 'Share this route',
toast_link_copied: 'Link copied to clipboard.',
install_banner_android: '📲 Install MeridianWay on your home screen for faster access.',
Expand Down Expand Up @@ -533,6 +541,8 @@ export const translations = {
error_geo_denied: 'Refusà il lieu dal navigatur.',
error_geo_timeout: 'Il lieu n’è betg stà pussaivel d’eruir. Empruvar per plaschair anc ina giada.',
about_privacy: 'caracter confidenzial',
optout_label: 'Statisticas da visitas',
optout_hint: 'Senza cookie. Deactivai questa opziun, sche Vus na vulessas betg che quest apparat vegnia dumbrà.',
share_btn_title: 'Parta questa ruta',
toast_link_copied: 'Copià la colliaziun en l’archiv provisoric.',
install_banner_android: '📲 L’installaziun da MeridianWay sin tes visur da partenza per in access pli svelt.',
Expand Down
53 changes: 53 additions & 0 deletions src/lib/sentryScrub.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { READY } from './errors.js';

// Keeps the route and the typed addresses out of error reports. Search URLs
// carry them as from/to and fromq/toq, geocoder requests as q=.

export function stripQuery(url) {
return typeof url === 'string' ? url.replace(/[?#].*$/, '') : url;
}

// A READY error's message is the toast, which can quote the user's input
// (ADDRESS_NOT_FOUND). Its code says the same without it.
function redacted(value) {
return value instanceof Error && READY.has(value.code) ? `${value.name}: ${value.code}` : null;
}

export function scrubEvent(event, hint) {
if (event.request) {
event.request.url = stripQuery(event.request.url);
const headers = event.request.headers;
if (headers?.Referer) headers.Referer = stripQuery(headers.Referer);
}
const err = hint?.originalException;
if (redacted(err)) {
for (const ex of event.exception?.values ?? []) {
if (ex.value === err.message) ex.value = err.code;
}
}
return event;
}

export function scrubBreadcrumb(crumb, hint) {
const { category, data } = crumb;
if ((category === 'fetch' || category === 'xhr') && data) {
data.url = stripQuery(data.url);
}
if (category === 'navigation' && data) {
data.from = stripQuery(data.from);
data.to = stripQuery(data.to);
}
if (category === 'console') {
const args = hint?.input ?? [];
if (args.some(redacted)) {
const safe = args.map((a) => redacted(a) ?? a);
crumb.message = safe.map(String).join(' ');
crumb.data = { ...data, arguments: safe };
}
}
// Autocomplete suggestions carry the place name as their title.
if (category?.startsWith('ui.') && crumb.message) {
crumb.message = crumb.message.replace(/\[title=".*?"\]/g, '');
}
return crumb;
}
5 changes: 5 additions & 0 deletions src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,11 @@ p.opensource-badge {
.github-link { color: #f0f2a0; border-color: rgba(240,242,160,0.3); background: rgba(240,242,160,0.05); }
.github-link:hover { border-color: rgba(240,242,160,0.6); background: rgba(240,242,160,0.1); }

#about-optout {
padding: 12px 20px;
border-top: 1px solid rgba(240,242,160,0.1);
}

.footer-links {
display: flex;
justify-content: center;
Expand Down
50 changes: 50 additions & 0 deletions tests/analytics.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { coarsenUrl, umamiBeforeSend } from '../src/lib/analytics.js';

const SEARCH_URL = 'https://meridian-way.ch/?from=46.51860%2C6.56800&fromq=Avenue+Probe+12%2C+Ecublens&to=46.53730%2C6.58530&toq=Renens&dt=2026-09-25T08%3A27';

describe('coarsenUrl', () => {
it('rounds the route points to ~100 m and drops the typed labels', () => {
expect(coarsenUrl(SEARCH_URL)).toBe('https://meridian-way.ch/?from=46.519%2C6.568&to=46.537%2C6.585&dt=2026-09-25T08%3A27');
});

it('works on the relative referrer Umami sends for same-origin pages', () => {
expect(coarsenUrl('/de/?from=46.5186,6.568&fromq=x&to=46.5373,6.5853')).toBe('/de/?from=46.519%2C6.568&to=46.537%2C6.585');
});

it('keeps utm tags and the onboarding flag', () => {
expect(coarsenUrl('/?from=1.23456,2.34567&to=3,4&onboarding=1&utm_source=reddit'))
.toBe('/?from=1.235%2C2.346&to=3.000%2C4.000&onboarding=1&utm_source=reddit');
});

it('leaves the query alone when it holds no route', () => {
const url = 'https://meridian-way.ch/it/?utm_source=chatgpt.com';
expect(coarsenUrl(url)).toBe(url);
});

it('drops a query left empty and keeps the hash', () => {
expect(coarsenUrl('/?fromq=Home#map')).toBe('/#map');
});

it('does not touch a from that is not a point', () => {
expect(coarsenUrl('/?from=newsletter')).toBe('/?from=newsletter');
});

it('passes through a missing value', () => {
expect(coarsenUrl(undefined)).toBeUndefined();
expect(coarsenUrl('')).toBe('');
});
});

describe('umamiBeforeSend', () => {
it('coarsens the url and the referrer and keeps the rest of the payload', () => {
const payload = { website: 'w', url: SEARCH_URL, referrer: '/?fromq=Home&from=1,2', name: 'scrub', data: { a: 1 } };
expect(umamiBeforeSend('event', payload)).toEqual({
website: 'w',
url: 'https://meridian-way.ch/?from=46.519%2C6.568&to=46.537%2C6.585&dt=2026-09-25T08%3A27',
referrer: '/?from=1.000%2C2.000',
name: 'scrub',
data: { a: 1 },
});
});
});
Loading
Loading