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
37 changes: 36 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,43 @@ by name. The brand name is `Boundfox Studios`, one word.
where `o<n.length` reads as the start of a tag, and then report every page as clean.
That is why the nightly link check runs linkinator.

## Measurement

- Production page views go to the self-hosted Matomo at `werhettdoneigeguggd.de`, configured
with: no cookies apart from the objection cookie `mtm_consent_removed`, no browser feature
detection, no performance timings, no outlink tracking (outbound traffic is visible on the
target services), only campaign query parameters, 404 titles without query or full referrer.
Of these, the privacy policy covers only the cookies. Do Not Track and Global Privacy Control
keep `matomo.js` from loading at all. Changing what the tracker sends is a privacy policy
change and moves `PRIVACY_LAST_UPDATED`. Until this change the site ran no client-side
measurement (issue #53).
- Only the production origin tracks, decided at runtime against `SITE.origin` in the
browser-only `app.config.browser.ts`. CI, preview and production ship the identical build, so a
build flag was rejected: it would rest on a workflow input.
- The tracker is never written during prerender. CI fails on `matomo.js`, `matomo.php` or `_paq`
in prerendered HTML, so the privacy prose names no tracker file; the JS bundles contain them on
purpose. `npm run axe` aborts and fails on any request that leaves the local server, which is
what proves the inactive path.
- Once loaded, `matomo.js` replaces the `window._paq` array with its own proxy. Every command
reads `window._paq` at call time; a stored reference silently drops all later page views and
the opt-out.
- `forgetUserOptOut` exists but must not be used: it calls `setConsentGiven`, which re-enables
browser feature detection and flushes the hits queued while the visitor was opted out.
Withdrawing an objection deletes `mtm_consent_removed` instead.
- A stored objection keeps `matomo.js` from loading and is rewritten on every load: Safari caps
cookies written by script at seven days, so an objection written once would lapse silently.
- `NavigationEnd` fires before the title strategy updates `document.title`. Page views take their
title from the route's `seo` data.
- A future CSP needs `script-src`, `connect-src` and `img-src` for
`https://werhettdoneigeguggd.de`.
- Rejected: ngx-matomo-client (peer range pinned to the Angular major, no cookieless or
feature-detection options), the inline snippet (lands in every prerendered page, previews
included), a first-party proxy (EasyPrivacy blocks by path, not host).

## Conventions

Code style, testing, branches, commit messages and pull requests follow the general
conventions. In short: no comments that paraphrase code, no abbreviations in identifiers,
English code and commits, German only in translatable strings.
English code and commits, German only in translatable strings. Exception: the legal prose and
`bfs-tracking-opt-out` are German without i18n on purpose and appear under `/en/` inside the
`lang="de"` wrapper.
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ jobs:
- run: npm run build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Nothing is measured
- name: No third-party analytics, and no Matomo tracker in prerendered HTML
run: |
if grep -rniE 'cloudflareinsights|beacon\.min\.js|googletagmanager|google-analytics|plausible\.io|matomo\.(js|php)|piwik\.(js|php)|_paq' dist/website/browser; then
echo "::error::an analytics script reached the build"
if grep -rniE 'cloudflareinsights|beacon\.min\.js|googletagmanager|google-analytics|plausible\.io' dist/website/browser \
|| grep -rniE --include='*.html' 'matomo\.(js|php)|piwik\.(js|php)|_paq' dist/website/browser; then
echo "::error::a third-party analytics script or a prerendered Matomo tracker reached the build"
exit 1
fi
- run: npm run lighthouse
Expand Down
4 changes: 4 additions & 0 deletions projects/website/src/app/analytics/analytics.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const MATOMO = {
origin: 'https://werhettdoneigeguggd.de',
siteId: '7',
} as const;
117 changes: 117 additions & 0 deletions projects/website/src/app/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { provideLocationMocks } from '@angular/common/testing';
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';

import { AnalyticsService } from './analytics.service';

type QueuedCommand = readonly unknown[];

interface TrackerWindow extends Window {
_paq?: { push(command: QueuedCommand): unknown };
}

@Component({ template: '' })
class TestPage {}

const trackerWindow = window as TrackerWindow;

const queuedCommands = (): readonly QueuedCommand[] =>
(trackerWindow._paq ?? []) as readonly QueuedCommand[];

const commandsNamed = (name: string): readonly QueuedCommand[] =>
queuedCommands().filter(([commandName]) => commandName === name);

describe('AnalyticsService', () => {
let router: Router;

beforeEach(() => {
delete trackerWindow._paq;
document.cookie = 'mtm_consent_removed=; path=/; max-age=0';

TestBed.configureTestingModule({
providers: [
provideRouter([
{
path: '',
component: TestPage,
data: { seo: { title: 'Startseite', description: '' } },
},
{
path: 'support',
component: TestPage,
data: { seo: { title: 'Unterstützen', description: '' } },
},
]),
provideLocationMocks(),
],
});

router = TestBed.inject(Router);
TestBed.inject(AnalyticsService).start();
});

it('configures cookieless tracking without feature detection or performance timings', () => {
expect(queuedCommands()).toEqual(
expect.arrayContaining([
['disableCookies'],
['disableBrowserFeatureDetection'],
['disablePerformanceTracking'],
]),
);
});

it('tracks the landing page once, titled from its route', async () => {
await router.navigateByUrl('/');

expect(commandsNamed('trackPageView')).toHaveLength(1);
expect(commandsNamed('setDocumentTitle')).toEqual([['setDocumentTitle', 'Startseite']]);
});

it('sends the previous page as referrer on an in-app navigation', async () => {
await router.navigateByUrl('/');
const [, landingUrl] = commandsNamed('setCustomUrl')[0];
const commandsBeforeSupport = queuedCommands().length;

await router.navigateByUrl('/support');
const supportPageView = queuedCommands().slice(commandsBeforeSupport);

expect(commandsNamed('setReferrerUrl')).toEqual([['setReferrerUrl', landingUrl]]);
expect(supportPageView).toContainEqual(['setReferrerUrl', landingUrl]);
expect(supportPageView.at(-1)).toEqual(['trackPageView']);
});

it('keeps tracking after Matomo replaces its queue', async () => {
const replacementQueue: QueuedCommand[] = [];

trackerWindow._paq = {
push: (command: QueuedCommand): number => replacementQueue.push(command),
};

await router.navigateByUrl('/support');

expect(replacementQueue).toContainEqual(['trackPageView']);
});

it('objecting stops Matomo through optUserOut', () => {
const analytics = TestBed.inject(AnalyticsService);

analytics.optOut();

expect(commandsNamed('optUserOut')).toEqual([['optUserOut']]);
expect(analytics.status()).toBe('opted-out');
});

it('withdrawing the objection deletes the opt-out cookie without forgetUserOptOut', () => {
history.replaceState(null, '', '/privacy-policy/');
onTestFinished(() => {
history.replaceState(null, '', '/');
});
document.cookie = 'mtm_consent_removed=1792345678901; path=/';

TestBed.inject(AnalyticsService).revokeOptOut();

expect(document.cookie).not.toContain('mtm_consent_removed');
expect(commandsNamed('forgetUserOptOut')).toEqual([]);
});
});
144 changes: 144 additions & 0 deletions projects/website/src/app/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {
afterNextRender,
DestroyRef,
DOCUMENT,
EnvironmentProviders,
inject,
Injectable,
provideAppInitializer,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';

import { resolvePageSeo } from '../seo/resolve-page-seo';
import { MATOMO } from './analytics.config';
import {
notFoundTitle,
OPT_OUT_COOKIE,
optOutCookieValue,
pageViewUrl,
trackingState,
} from './tracking-rules';

export type TrackingStatus = 'unknown' | 'off' | 'on' | 'opted-out' | 'on-next-load';

const OPT_OUT_COOKIE_LIFETIME_IN_SECONDS = 30 * 365 * 24 * 60 * 60;

type MatomoCommand = readonly [string, ...unknown[]] | readonly [() => void];

interface MatomoWindow extends Window {
_paq?: { push(command: MatomoCommand): unknown };
}

@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private readonly document = inject(DOCUMENT);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly trackingStatus = signal<TrackingStatus>('unknown');

readonly status = this.trackingStatus.asReadonly();

start(): void {
this.push(['setTrackerUrl', `${MATOMO.origin}/matomo.php`]);
this.push(['setSiteId', MATOMO.siteId]);
this.push(['disableCookies']);
this.push(['disableBrowserFeatureDetection']);
this.push(['disablePerformanceTracking']);
this.push([
(): void => {
this.trackingStatus.set('on');
},
]);

let previousUrl: string | undefined;

this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(event => {
const url = pageViewUrl(event.urlAfterRedirects, this.document.baseURI);

if (previousUrl !== undefined) {
this.push(['setReferrerUrl', previousUrl]);
}

this.push(['setCustomUrl', url]);
this.push(['setDocumentTitle', this.pageTitle(url, previousUrl)]);
this.push(['trackPageView']);

previousUrl = url;
});
}

loadTracker(): void {
const script = this.document.createElement('script');

script.async = true;
script.src = `${MATOMO.origin}/matomo.js`;
script.addEventListener('error', () => {
this.trackingStatus.set('off');
});

this.document.head.appendChild(script);
}

decline(state: 'opted-out' | 'off'): void {
if (state === 'opted-out') {
// Safari caps cookies written by script at seven days, so only rewriting the objection on
// every load keeps it from lapsing.
this.document.cookie = `${OPT_OUT_COOKIE}=${optOutCookieValue(this.document.cookie)}; path=/; max-age=${OPT_OUT_COOKIE_LIFETIME_IN_SECONDS}; SameSite=Lax`;
}

this.trackingStatus.set(state);
}

optOut(): void {
this.push(['optUserOut']);
this.trackingStatus.set('opted-out');
}

revokeOptOut(): void {
// Not forgetUserOptOut: it calls setConsentGiven, which turns browser feature detection back on
// and sends the hits queued while the visitor was opted out.
this.document.cookie = `${OPT_OUT_COOKIE}=; path=/; max-age=0`;
this.trackingStatus.set('on-next-load');
}

private pageTitle(url: string, previousUrl: string | undefined): string {
// `NavigationEnd` fires before the title strategy updates `document.title`.
const seo = resolvePageSeo(this.router.routerState.snapshot.root);

if (seo?.noIndex) {
return notFoundTitle(url, previousUrl ?? this.document.referrer);
}

return seo?.title ?? this.document.title;
}

private push(command: MatomoCommand): void {
// Once loaded, matomo.js replaces this array with its own proxy, so a stored reference would
// silently drop every later command.
((window as MatomoWindow)._paq ??= [] as MatomoCommand[]).push(command);
}
}

export function provideAnalytics(): EnvironmentProviders {
return provideAppInitializer(() => {
const analytics = inject(AnalyticsService);
const state = trackingState(location.origin, navigator, document.cookie);

if (state === 'allowed') {
analytics.start();
afterNextRender(() => {
analytics.loadTracker();
});
} else {
analytics.decline(state);
}
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<p
class="mt-4 text-base leading-relaxed"
#statusParagraph
tabindex="-1"
>
{{ statusText() }}
</p>
@if (buttonLabel()) {
<button
class="mt-4 inline-block cursor-pointer rounded-lg bg-amber px-5 py-2.5 font-display text-base tracking-wider text-neutral-900 uppercase shadow-accent focus-ring transition-colors duration-150 ease-in-out hover:bg-orange hover:text-neutral-900 active:translate-y-px"
(click)="changeObjection()"
type="button"
>
{{ buttonLabel() }}
</button>
}
<p
class="sr-only"
[textContent]="announcement()"
role="status"
></p>
Loading
Loading