From df571df567644288f3a6b4828e0c0760b7b05f29 Mon Sep 17 00:00:00 2001 From: Robin Choice Date: Thu, 23 Apr 2026 10:21:56 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20listen=20analytics=20=E2=80=94=20track?= =?UTF-8?q?=20who=20heard=20what=20and=20when?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add listen_events table to record opens, plays and listen duration per share link. Public POST/PATCH endpoints for fire-and-forget tracking from the listen page; authenticated GET analytics endpoint aggregates per version. Listen page gains optional name prompt after first play and sendBeacon on unload. Track page gains Analytics tab with stats grid and per-listener event list. Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/routes/share.ts | 128 +++++++++- .../components/audio/WaveformPlayer.svelte | 11 +- .../[projectId]/tracks/[trackId]/+page.svelte | 8 +- .../components/AnalyticsPanel.svelte | 164 +++++++++++++ .../src/routes/listen/[token]/+page.svelte | 230 +++++++++++++----- .../db/src/migrations/0008_listen_events.sql | 13 + packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/shareLinks.ts | 16 +- packages/shared/src/validation/push.ts | 7 + specs/listen-analytics.md | 54 ++++ 10 files changed, 569 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/AnalyticsPanel.svelte create mode 100644 packages/db/src/migrations/0008_listen_events.sql create mode 100644 specs/listen-analytics.md diff --git a/apps/api/src/routes/share.ts b/apps/api/src/routes/share.ts index 0daf414..2b6cfa7 100644 --- a/apps/api/src/routes/share.ts +++ b/apps/api/src/routes/share.ts @@ -1,9 +1,10 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; -import { eq, and, asc } from 'drizzle-orm'; -import { createShareLinkSchema, guestCommentSchema } from '@music-hub/shared'; +import { eq, and, asc, desc, inArray } from 'drizzle-orm'; +import { createShareLinkSchema, guestCommentSchema, updateListenEventSchema } from '@music-hub/shared'; import { shareLinks, + listenEvents, versions, tracks, projects, @@ -13,6 +14,11 @@ import { } from '@music-hub/db'; import { requireAuth } from '../middleware/auth.js'; import { createDownloadUrl } from '../storage/s3.js'; + +async function hashIp(ip: string): Promise { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip + 'musichub-salt')); + return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 16); +} import type { AppEnv } from '../types.js'; function generateToken(): string { @@ -262,4 +268,122 @@ export const shareRoutes = new Hono() .returning(); return c.json({ comment }, 201); + }) + + // --- Listen tracking (public, no auth) --- + .post('/public/:token/listen', async (c) => { + const db = c.get('db'); + const token = c.req.param('token'); + + const [link] = await db + .select({ id: shareLinks.id, expiresAt: shareLinks.expiresAt }) + .from(shareLinks) + .where(eq(shareLinks.token, token)) + .limit(1); + if (!link) return c.json({ error: 'Not found' }, 404); + if (link.expiresAt && link.expiresAt < new Date()) return c.json({ error: 'Expired' }, 410); + + const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.header('cf-connecting-ip') ?? 'unknown'; + const ipHash = await hashIp(ip); + const userAgent = (c.req.header('user-agent') ?? '').slice(0, 500); + + const [event] = await db + .insert(listenEvents) + .values({ shareLinkId: link.id, ipHash, userAgent }) + .returning({ id: listenEvents.id }); + + return c.json({ eventId: event.id }, 201); + }) + + .patch('/public/:token/listen/:eventId', zValidator('json', updateListenEventSchema), async (c) => { + const db = c.get('db'); + const token = c.req.param('token'); + const eventId = c.req.param('eventId'); + const input = c.req.valid('json'); + + const [link] = await db + .select({ id: shareLinks.id }) + .from(shareLinks) + .where(eq(shareLinks.token, token)) + .limit(1); + if (!link) return c.json({ error: 'Not found' }, 404); + + const [event] = await db + .select() + .from(listenEvents) + .where(and(eq(listenEvents.id, eventId), eq(listenEvents.shareLinkId, link.id))) + .limit(1); + if (!event) return c.json({ error: 'Not found' }, 404); + + await db + .update(listenEvents) + .set({ + ...(input.listenerName !== undefined ? { listenerName: input.listenerName } : {}), + ...(input.firstPlay && !event.firstPlayAt ? { firstPlayAt: new Date() } : {}), + ...(input.listenSeconds !== undefined ? { listenSeconds: input.listenSeconds } : {}), + ...(input.completed !== undefined ? { completed: input.completed } : {}), + }) + .where(eq(listenEvents.id, eventId)); + + return c.json({ ok: true }); + }) + + // --- Analytics (authenticated) --- + .get('/version/:versionId/analytics', requireAuth, async (c) => { + const db = c.get('db'); + const userId = c.get('userId'); + const versionId = c.req.param('versionId'); + + const [version] = await db.select().from(versions).where(eq(versions.id, versionId)).limit(1); + if (!version) return c.json({ error: 'Not found' }, 404); + + const [track] = await db.select().from(tracks).where(eq(tracks.id, version.trackId)).limit(1); + const [membership] = await db + .select() + .from(projectMembers) + .where(and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId))) + .limit(1); + if (!membership) return c.json({ error: 'Forbidden' }, 403); + + const links = await db + .select({ id: shareLinks.id }) + .from(shareLinks) + .where(eq(shareLinks.versionId, versionId)); + + if (links.length === 0) return c.json({ totalOpens: 0, totalPlays: 0, uniqueListeners: 0, avgListenSeconds: 0, completionRate: 0, events: [] }); + + const linkIds = links.map((l) => l.id); + + const events = await db + .select() + .from(listenEvents) + .where(inArray(listenEvents.shareLinkId, linkIds)) + .orderBy(desc(listenEvents.openedAt)); + + const totalOpens = events.length; + const played = events.filter((e) => e.firstPlayAt !== null); + const totalPlays = played.length; + const uniqueListeners = new Set(events.map((e) => e.ipHash)).size; + const avgListenSeconds = played.length > 0 + ? Math.round(played.reduce((s, e) => s + e.listenSeconds, 0) / played.length) + : 0; + const completionRate = totalPlays > 0 + ? Math.round((events.filter((e) => e.completed).length / totalPlays) * 100) + : 0; + + return c.json({ + totalOpens, + totalPlays, + uniqueListeners, + avgListenSeconds, + completionRate, + events: events.map((e) => ({ + id: e.id, + listenerName: e.listenerName, + openedAt: e.openedAt, + firstPlayAt: e.firstPlayAt, + listenSeconds: e.listenSeconds, + completed: e.completed, + })), + }); }); diff --git a/apps/web/src/lib/components/audio/WaveformPlayer.svelte b/apps/web/src/lib/components/audio/WaveformPlayer.svelte index e6a5521..e41e6f2 100644 --- a/apps/web/src/lib/components/audio/WaveformPlayer.svelte +++ b/apps/web/src/lib/components/audio/WaveformPlayer.svelte @@ -22,6 +22,9 @@ onTimeClick, onReady, onSeek, + onPlay, + onPause, + onFinish, }: { url: string; markers?: CommentMarker[]; @@ -33,6 +36,9 @@ onTimeClick?: (time: number) => void; onReady?: (duration: number) => void; onSeek?: (time: number) => void; + onPlay?: () => void; + onPause?: () => void; + onFinish?: () => void; } = $props(); let container: HTMLDivElement; @@ -86,8 +92,9 @@ onSeek?.(time); }); - ws.on('play', () => (isPlaying = true)); - ws.on('pause', () => (isPlaying = false)); + ws.on('play', () => { isPlaying = true; onPlay?.(); }); + ws.on('pause', () => { isPlaying = false; onPause?.(); }); + ws.on('finish', () => { isPlaying = false; onFinish?.(); }); ws.on('click', (relativeX) => { if (onTimeClick) { diff --git a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte index 0bca515..4fd7862 100644 --- a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte +++ b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte @@ -33,6 +33,7 @@ import ShareModal from './components/ShareModal.svelte'; import CommentSection from './components/CommentSection.svelte'; import StemList, { type Stem } from './components/StemList.svelte'; + import AnalyticsPanel from './components/AnalyticsPanel.svelte'; type Version = { id: string; @@ -96,7 +97,7 @@ let branchLabelInput = $state(''); let shareOpen = $state(false); let stems = $state([]); - let panelTab = $state<'versions' | 'comments' | 'stems'>('versions'); + let panelTab = $state<'versions' | 'comments' | 'stems' | 'analytics'>('versions'); let panelOpen = $state(true); let editVersionOpen = $state(false); let editVersionLabel = $state(''); @@ -585,6 +586,9 @@ +
@@ -610,6 +614,8 @@ currentUserId={$user?.id ?? null} {role} /> + {:else if panelTab === 'analytics' && selectedVersion} + {:else if selectedVersion} + import { api } from '$lib/api/client.js'; + import Icon from '$lib/components/ui/Icon.svelte'; + + type ListenEvent = { + id: string; + listenerName: string | null; + openedAt: string; + firstPlayAt: string | null; + listenSeconds: number; + completed: boolean; + }; + + type Analytics = { + totalOpens: number; + totalPlays: number; + uniqueListeners: number; + avgListenSeconds: number; + completionRate: number; + events: ListenEvent[]; + }; + + let { versionId }: { versionId: string } = $props(); + + let data = $state(null); + let loading = $state(true); + let error = $state(''); + + $effect(() => { + if (versionId) load(); + }); + + async function load() { + loading = true; + error = ''; + try { + data = await api.get(`/share/version/${versionId}/analytics`); + } catch { + error = 'Analytics konnten nicht geladen werden.'; + } finally { + loading = false; + } + } + + function formatDuration(s: number): string { + if (s < 60) return `${s}s`; + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; + } + + function formatDate(iso: string): string { + return new Date(iso).toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + } + + +
+ {#if loading} +

Lädt…

+ {:else if error} +

{error}

+ {:else if data} + {#if data.totalOpens === 0} +

Noch keine Aufrufe. Teile einen Link um Analytics zu sehen.

+ {:else} +
+
+ {data.totalOpens} + Geöffnet +
+
+ {data.totalPlays} + Abgespielt +
+
+ {data.uniqueListeners} + Personen +
+
+ {data.completionRate}% + Abschluss +
+
+ +
+ {#each data.events as e (e.id)} +
+
+ {e.listenerName ?? 'Anonym'} + + {formatDate(e.openedAt)} + {#if e.firstPlayAt} + · {formatDuration(e.listenSeconds)} gehört + {#if e.completed}✓ Komplett{/if} + {:else} + · nicht abgespielt + {/if} + +
+
+ {/each} +
+ {/if} + {/if} +
+ + diff --git a/apps/web/src/routes/listen/[token]/+page.svelte b/apps/web/src/routes/listen/[token]/+page.svelte index 653f38e..f01373a 100644 --- a/apps/web/src/routes/listen/[token]/+page.svelte +++ b/apps/web/src/routes/listen/[token]/+page.svelte @@ -47,6 +47,18 @@ let submitting = $state(false); let playerRef = $state(); + // Analytics tracking + let eventId = $state(null); + let listenSeconds = $state(0); + let playStartedAt = $state(null); + let firstPlayFired = $state(false); + let progressInterval = $state | null>(null); + + // Name prompt + let showNamePrompt = $state(false); + let nameInput = $state(''); + let nameDismissed = $state(false); + async function load() { loading = true; error = ''; @@ -56,11 +68,7 @@ }); if (res.status === 401) { const j = await res.json(); - if (j.passwordRequired) { - passwordRequired = true; - loading = false; - return; - } + if (j.passwordRequired) { passwordRequired = true; loading = false; return; } } if (!res.ok) { error = (await res.json().catch(() => ({}))).error || 'Link nicht verfügbar'; @@ -74,7 +82,93 @@ } } - onMount(load); + async function createListenEvent() { + try { + const res = await fetch(`/api/v1/share/public/${token}/listen`, { method: 'POST' }); + if (res.ok) { + const j = await res.json(); + eventId = j.eventId; + } + } catch { /* fire and forget */ } + } + + async function patchEvent(patch: Record) { + if (!eventId) return; + try { + await fetch(`/api/v1/share/public/${token}/listen/${eventId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + } catch { /* fire and forget */ } + } + + function onPlay() { + playStartedAt = Date.now(); + if (!firstPlayFired) { + firstPlayFired = true; + patchEvent({ firstPlay: true }); + // Show name prompt after first play, if name not yet set + const saved = localStorage.getItem('listenName'); + if (saved) { + guestName = saved; + patchEvent({ listenerName: saved }); + } else if (!nameDismissed) { + showNamePrompt = true; + } + } + progressInterval = setInterval(() => { + if (playStartedAt !== null) { + listenSeconds += 1; + if (listenSeconds % 30 === 0) patchEvent({ listenSeconds }); + } + }, 1000); + } + + function onPause() { + if (progressInterval) { clearInterval(progressInterval); progressInterval = null; } + patchEvent({ listenSeconds }); + } + + function onFinish() { + if (progressInterval) { clearInterval(progressInterval); progressInterval = null; } + const duration = data?.version.duration ?? 0; + const pct = duration > 0 ? listenSeconds / duration : 0; + patchEvent({ listenSeconds, completed: pct >= 0.8 }); + } + + function submitName() { + const name = nameInput.trim(); + if (name) { + guestName = name; + localStorage.setItem('listenName', name); + patchEvent({ listenerName: name }); + } + showNamePrompt = false; + nameDismissed = true; + } + + function dismissName() { + showNamePrompt = false; + nameDismissed = true; + } + + onMount(() => { + load().then(() => createListenEvent()); + + const saved = localStorage.getItem('listenName'); + if (saved) guestName = saved; + + const handleUnload = () => { + if (!eventId) return; + navigator.sendBeacon( + `/api/v1/share/public/${token}/listen/${eventId}`, + new Blob([JSON.stringify({ listenSeconds })], { type: 'application/json' }), + ); + }; + window.addEventListener('beforeunload', handleUnload); + return () => window.removeEventListener('beforeunload', handleUnload); + }); async function submitComment(e: Event) { e.preventDefault(); @@ -87,16 +181,9 @@ 'Content-Type': 'application/json', ...(password ? { 'X-Share-Password': password } : {}), }, - body: JSON.stringify({ - body, - guestName, - timestampSeconds: commentTimestamp ?? undefined, - }), + body: JSON.stringify({ body, guestName, timestampSeconds: commentTimestamp ?? undefined }), }); - if (!res.ok) { - error = 'Kommentar fehlgeschlagen'; - return; - } + if (!res.ok) { error = 'Kommentar fehlgeschlagen'; return; } body = ''; commentTimestamp = null; await load(); @@ -142,8 +229,28 @@ userName: c.user?.name ?? c.guestName ?? 'Gast', }))} onTimeClick={(t) => (commentTimestamp = Math.round(t * 10) / 10)} + onPlay={onPlay} + onPause={onPause} + onFinish={onFinish} /> + {#if showNamePrompt} +
+

Wie heißt du? So wissen die Künstler, wer zugehört hat.

+
+ e.key === 'Enter' && submitName()} + autofocus + /> + + +
+
+ {/if} + {#if data.downloadUrl}
@@ -210,30 +317,49 @@ flex-direction: column; gap: var(--space-5); } - header { - text-align: center; + header { text-align: center; } + .project { color: var(--color-text-tertiary); font-size: var(--text-sm); margin: 0; } + h1 { margin: var(--space-1) 0; font-size: var(--text-2xl); } + .version-label { color: var(--color-text-secondary); font-size: var(--text-sm); margin: 0; } + .muted { color: var(--color-text-tertiary); font-size: var(--text-sm); } + .error { color: var(--color-error, #ef4444); } + + .name-prompt { + background: var(--color-bg-raised); + border: 1px solid var(--color-accent); + border-radius: var(--radius-md); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); } - .project { + .name-prompt p { margin: 0; font-size: var(--text-sm); color: var(--color-text-secondary); } + .name-prompt-row { + display: flex; + gap: var(--space-2); + align-items: center; + } + .name-prompt-row input { + flex: 1; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-md); + border: 1px solid var(--color-border-hover); + background: var(--color-bg-base); + color: var(--color-text-primary); + font-family: inherit; + font-size: var(--text-sm); + } + .skip-btn { + background: none; + border: none; color: var(--color-text-tertiary); font-size: var(--text-sm); - margin: 0; - } - h1 { - margin: var(--space-1) 0; - font-size: var(--text-2xl); - } - .version-label { - color: var(--color-text-secondary); - font-size: var(--text-sm); - margin: 0; - } - .muted { - color: var(--color-text-tertiary); - font-size: var(--text-sm); - } - .error { - color: var(--color-error, #ef4444); + cursor: pointer; + font-family: inherit; + white-space: nowrap; } + .skip-btn:hover { color: var(--color-text-secondary); } + .password-gate { text-align: center; padding: var(--space-8); @@ -254,10 +380,7 @@ font-size: var(--text-sm); width: 100%; } - .actions { - display: flex; - justify-content: flex-end; - } + .actions { display: flex; justify-content: flex-end; } .comment-form { display: flex; flex-direction: column; @@ -267,10 +390,7 @@ border: 1px solid var(--color-border); border-radius: var(--radius-md); } - .comment-form h2 { - margin: 0 0 var(--space-2); - font-size: var(--text-base); - } + .comment-form h2 { margin: 0 0 var(--space-2); font-size: var(--text-base); } .ts-badge { align-self: flex-start; background: rgba(251, 191, 36, 0.15); @@ -280,22 +400,9 @@ padding: 0.15rem var(--space-2); font-size: var(--text-xs); } - .ts-badge button { - background: none; - border: none; - color: inherit; - cursor: pointer; - margin-left: var(--space-1); - } - .comments { - display: flex; - flex-direction: column; - gap: var(--space-2); - } - .comments h2 { - font-size: var(--text-base); - margin: 0 0 var(--space-2); - } + .ts-badge button { background: none; border: none; color: inherit; cursor: pointer; margin-left: var(--space-1); } + .comments { display: flex; flex-direction: column; gap: var(--space-2); } + .comments h2 { font-size: var(--text-base); margin: 0 0 var(--space-2); } .comment { padding: var(--space-3); background: var(--color-bg-raised); @@ -327,10 +434,7 @@ cursor: pointer; font-family: inherit; } - .comment p { - margin: 0; - font-size: var(--text-sm); - } + .comment p { margin: 0; font-size: var(--text-sm); } footer { text-align: center; padding-top: var(--space-4); diff --git a/packages/db/src/migrations/0008_listen_events.sql b/packages/db/src/migrations/0008_listen_events.sql new file mode 100644 index 0000000..d9a04aa --- /dev/null +++ b/packages/db/src/migrations/0008_listen_events.sql @@ -0,0 +1,13 @@ +CREATE TABLE "listen_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "share_link_id" uuid NOT NULL, + "listener_name" varchar(255), + "ip_hash" varchar(64), + "user_agent" varchar(500), + "opened_at" timestamp DEFAULT now() NOT NULL, + "first_play_at" timestamp, + "listen_seconds" integer DEFAULT 0 NOT NULL, + "completed" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +ALTER TABLE "listen_events" ADD CONSTRAINT "listen_events_share_link_id_share_links_id_fk" FOREIGN KEY ("share_link_id") REFERENCES "public"."share_links"("id") ON DELETE cascade ON UPDATE no action; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 66801fd..5251c17 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1745395200000, "tag": "0007_push_subscriptions", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1745481600000, + "tag": "0008_listen_events", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/shareLinks.ts b/packages/db/src/schema/shareLinks.ts index 4ae46cc..85c3444 100644 --- a/packages/db/src/schema/shareLinks.ts +++ b/packages/db/src/schema/shareLinks.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, varchar, boolean, timestamp } from 'drizzle-orm/pg-core'; +import { pgTable, uuid, varchar, boolean, timestamp, integer } from 'drizzle-orm/pg-core'; import { users } from './users.js'; import { versions } from './tracks.js'; @@ -17,3 +17,17 @@ export const shareLinks = pgTable('share_links', { passwordHash: varchar('password_hash', { length: 255 }), createdAt: timestamp('created_at').defaultNow().notNull(), }); + +export const listenEvents = pgTable('listen_events', { + id: uuid('id').defaultRandom().primaryKey(), + shareLinkId: uuid('share_link_id') + .references(() => shareLinks.id, { onDelete: 'cascade' }) + .notNull(), + listenerName: varchar('listener_name', { length: 255 }), + ipHash: varchar('ip_hash', { length: 64 }), + userAgent: varchar('user_agent', { length: 500 }), + openedAt: timestamp('opened_at').defaultNow().notNull(), + firstPlayAt: timestamp('first_play_at'), + listenSeconds: integer('listen_seconds').default(0).notNull(), + completed: boolean('completed').default(false).notNull(), +}); diff --git a/packages/shared/src/validation/push.ts b/packages/shared/src/validation/push.ts index e51bfc0..71adabe 100644 --- a/packages/shared/src/validation/push.ts +++ b/packages/shared/src/validation/push.ts @@ -8,3 +8,10 @@ export const subscribePushSchema = z.object({ }), userAgent: z.string().optional(), }); + +export const updateListenEventSchema = z.object({ + listenerName: z.string().max(255).optional(), + firstPlay: z.boolean().optional(), + listenSeconds: z.number().int().min(0).optional(), + completed: z.boolean().optional(), +}); diff --git a/specs/listen-analytics.md b/specs/listen-analytics.md new file mode 100644 index 0000000..9ec9535 --- /dev/null +++ b/specs/listen-analytics.md @@ -0,0 +1,54 @@ +# Spec: Listen Analytics + +**Ziel:** Label-Manager und Produzenten sehen wer, wann und wie viel von einem geteilten Track gehört hat. + +**Pitch:** "Schick einen Link. Kein Account nötig. Du siehst wer wann gehört hat." + +--- + +## Was gebaut wird + +### 1. DB — `listen_events` + +| Feld | Typ | Beschreibung | +|---|---|---| +| `id` | uuid PK | — | +| `share_link_id` | uuid FK → share_links | Welcher Link | +| `listener_name` | varchar(255) nullable | Optional eingegeben | +| `ip_hash` | varchar(64) | SHA256(IP) — Dedup ohne PII | +| `user_agent` | text nullable | Browser-Info | +| `opened_at` | timestamp | Seite geladen | +| `first_play_at` | timestamp nullable | Erster Play-Klick | +| `listen_seconds` | integer default 0 | Kumulierte Hörzeit | +| `completed` | boolean default false | >80% des Tracks gehört | + +### 2. API + +| Route | Auth | Beschreibung | +|---|---|---| +| `POST /share/public/:token/listen` | — | Event anlegen, gibt `eventId` zurück | +| `PATCH /share/public/:token/listen/:eventId` | — | Name/Fortschritt/Abschluss updaten | +| `GET /share/version/:versionId/analytics` | requireAuth | Aggregierte Analytics | + +### 3. Listen-Seite (Frontend) + +- Soft Name-Prompt bei erstem Laden: "Wie heißt du?" — überspringbar, gespeichert in localStorage +- `onMount` → POST /listen → eventId in State +- `onFirstPlay` → PATCH mit `firstPlayAt` +- Alle 30s während Play → PATCH mit aktuellem `listenSeconds` +- `beforeunload` → `navigator.sendBeacon` mit finalem `listenSeconds` + `completed` + +### 4. Analytics-Panel (Track-Seite) + +Neuer "Analytik"-Button in der Track-Toolbar. Öffnet Modal mit: + +- Zähler: Geöffnet / Gespielt / Ø Hörzeit / Abschlussrate +- Tabelle: Name (oder "Anonym") · Datum · Hörzeit · Abgeschlossen ✓ + +--- + +## Out of scope (jetzt) + +- E-Mail-Alerts wenn jemand hört +- Heatmap welcher Teil des Tracks gehört wurde +- Link-spezifische vs. versions-aggregierte Ansicht (immer version-aggregiert)