Podcast-statistikk dashboard i admin-panelet (oppgave 30.4)

Nytt dashboard under /admin/podcast-stats som viser:
- Nøkkeltall: totale nedlastinger, unike lyttere, antall episoder
- Daglig trend med horisontale bar charts
- Topp-episoder rangert etter nedlastinger
- Klientfordeling (Apple Podcasts, Spotify, etc.) med stacked bar

Backend: GET /admin/podcast/stats spør podcast_download_stats-tabellen
(fylt av synops-stats CLI fra oppgave 30.3) og aggregerer per episode,
per dag, og per klient via jsonb_each_text.

Filtrering på tidsperiode (7/30/90/365 dager) og enkelt-episode.
This commit is contained in:
vegard 2026-03-18 23:42:23 +00:00
parent e394035a5e
commit 3e57adce46
6 changed files with 609 additions and 2 deletions

View file

@ -1585,3 +1585,55 @@ export function aiSuggestScript(
): Promise<AiSuggestScriptResponse> {
return post(accessToken, '/intentions/ai_suggest_script', req);
}
// =============================================================================
// Podcast-statistikk (oppgave 30.4)
// =============================================================================
export interface EpisodeTotal {
episode_id: string | null;
episode_title: string | null;
total_downloads: number;
total_unique_listeners: number;
first_date: string | null;
last_date: string | null;
days_with_data: number;
}
export interface DailyDownloads {
date: string;
downloads: number;
unique_listeners: number;
}
export interface ClientBreakdown {
client: string;
count: number;
}
export interface PodcastStatsResponse {
total_downloads: number;
total_unique_listeners: number;
episodes: EpisodeTotal[];
daily: DailyDownloads[];
clients: ClientBreakdown[];
}
/** Hent podcast-nedlastingsstatistikk for admin. */
export async function fetchPodcastStats(
accessToken: string,
params: { days?: number; episode_id?: string } = {}
): Promise<PodcastStatsResponse> {
const sp = new URLSearchParams();
if (params.days) sp.set('days', String(params.days));
if (params.episode_id) sp.set('episode_id', params.episode_id);
const qs = sp.toString();
const res = await fetch(`${BASE_URL}/admin/podcast/stats${qs ? `?${qs}` : ''}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
const body = await res.text();
throw new Error(`podcast stats failed (${res.status}): ${body}`);
}
return res.json();
}

View file

@ -109,6 +109,9 @@
<a href="/admin/webhooks" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
Webhooks
</a>
<a href="/admin/podcast-stats" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
Podcast
</a>
</nav>
</div>
</header>

View file

@ -0,0 +1,344 @@
<script lang="ts">
/**
* Admin — Podcast-statistikk (oppgave 30.4)
*
* Dashboard for nedlastingsstatistikk: per episode, trend over tid,
* topp-episoder, klientfordeling (Apple/Spotify/andre).
* Data fra podcast_download_stats (skrevet av synops-stats CLI).
*/
import { page } from '$app/stores';
import {
fetchPodcastStats,
type PodcastStatsResponse,
type EpisodeTotal,
type DailyDownloads
} from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
let data = $state<PodcastStatsResponse | null>(null);
let error = $state<string | null>(null);
let loading = $state(false);
// Filtre
let days = $state(30);
let selectedEpisode = $state<string>('');
$effect(() => {
if (!accessToken) return;
loadData();
});
async function loadData() {
if (!accessToken) return;
loading = true;
error = null;
try {
data = await fetchPodcastStats(accessToken, {
days,
episode_id: selectedEpisode || undefined
});
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
// Formatering
function formatNumber(n: number): string {
return n.toLocaleString('nb-NO');
}
function formatDate(d: string): string {
return new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
}
// Enkel bar chart: beregn bredde relativt til maks
function barWidth(value: number, max: number): number {
if (max === 0) return 0;
return Math.max(2, (value / max) * 100);
}
// Maks nedlastinger for daglig tidsserie (for bar chart)
const maxDaily = $derived((): number => {
if (!data || data.daily.length === 0) return 0;
return Math.max(...data.daily.map((d) => d.downloads));
});
// Maks nedlastinger for episode-liste
const maxEpisode = $derived((): number => {
if (!data || data.episodes.length === 0) return 0;
return Math.max(...data.episodes.map((e) => e.total_downloads));
});
// Maks klient-antall
const maxClient = $derived((): number => {
if (!data || data.clients.length === 0) return 0;
return Math.max(...data.clients.map((c) => c.count));
});
// Totalt klienter for prosentregning
const totalClientCount = $derived((): number => {
if (!data) return 0;
return data.clients.reduce((sum, c) => sum + c.count, 0);
});
// Klientfarger
function clientColor(client: string): string {
const colors: Record<string, string> = {
'Apple Podcasts': 'bg-purple-500',
Spotify: 'bg-green-500',
Overcast: 'bg-orange-500',
'Pocket Casts': 'bg-red-500',
'Podcast Addict': 'bg-blue-500',
Castro: 'bg-lime-500',
Castbox: 'bg-amber-500',
Chrome: 'bg-sky-500',
Safari: 'bg-cyan-500',
Firefox: 'bg-rose-500'
};
return colors[client] || 'bg-neutral-500';
}
const daysOptions = [
{ value: 7, label: '7 dager' },
{ value: 30, label: '30 dager' },
{ value: 90, label: '90 dager' },
{ value: 365, label: '1 år' }
];
</script>
<div class="min-h-screen bg-neutral-950 text-neutral-100 p-4 sm:p-8">
<div class="max-w-6xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-2xl font-bold">Podcast-statistikk</h1>
<a href="/admin" class="text-sm text-neutral-400 hover:text-white">Tilbake til admin</a>
</div>
{#if error}
<div class="bg-red-900/30 border border-red-700 rounded-lg p-4 text-red-300 text-sm">
{error}
</div>
{/if}
<!-- Filtre -->
<div class="flex flex-wrap gap-3 items-center">
<select
bind:value={days}
class="bg-neutral-800 border border-neutral-700 rounded px-3 py-1.5 text-sm"
onchange={() => loadData()}
>
{#each daysOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if data && data.episodes.length > 1}
<select
bind:value={selectedEpisode}
class="bg-neutral-800 border border-neutral-700 rounded px-3 py-1.5 text-sm max-w-xs"
onchange={() => loadData()}
>
<option value="">Alle episoder</option>
{#each data.episodes as ep}
<option value={ep.episode_id || ''}>
{ep.episode_title || ep.episode_id?.slice(0, 8) || 'Ukjent'}
</option>
{/each}
</select>
{/if}
<button
class="bg-neutral-800 hover:bg-neutral-700 px-3 py-1.5 rounded text-sm"
onclick={() => loadData()}
disabled={loading}
>
{loading ? 'Laster...' : 'Oppdater'}
</button>
</div>
{#if !data && !error}
<p class="text-neutral-500">Laster...</p>
{:else if data}
<!-- Nøkkeltall -->
<section>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
<div class="text-sm text-neutral-400 mb-1">Nedlastinger</div>
<div class="text-3xl font-mono font-bold">{formatNumber(data.total_downloads)}</div>
<div class="text-xs text-neutral-500 mt-1">siste {days} dager</div>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
<div class="text-sm text-neutral-400 mb-1">Unike lyttere</div>
<div class="text-3xl font-mono font-bold">
{formatNumber(data.total_unique_listeners)}
</div>
<div class="text-xs text-neutral-500 mt-1">siste {days} dager</div>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
<div class="text-sm text-neutral-400 mb-1">Episoder</div>
<div class="text-3xl font-mono font-bold">{data.episodes.length}</div>
<div class="text-xs text-neutral-500 mt-1">med nedlastinger</div>
</div>
</div>
</section>
<!-- Daglig trend -->
{#if data.daily.length > 0}
<section>
<h2 class="text-lg font-semibold mb-3">Daglig trend</h2>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
<div class="space-y-1">
{#each data.daily as day}
<div class="flex items-center gap-2 text-sm">
<span class="w-16 text-xs text-neutral-400 font-mono shrink-0">
{formatDate(day.date)}
</span>
<div class="flex-1 flex items-center gap-2">
<div
class="h-5 bg-blue-500/60 rounded-sm transition-all"
style="width: {barWidth(day.downloads, maxDaily())}%"
></div>
<span class="text-xs text-neutral-300 shrink-0">
{formatNumber(day.downloads)}
</span>
</div>
</div>
{/each}
</div>
<div class="mt-3 flex gap-4 text-xs text-neutral-500">
<span>Nedlastinger per dag (unike IP per episode)</span>
</div>
</div>
</section>
{/if}
<!-- Topp-episoder -->
{#if data.episodes.length > 0}
<section>
<h2 class="text-lg font-semibold mb-3">Topp-episoder</h2>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-neutral-800 text-left text-neutral-400">
<th class="px-4 py-3 font-medium">#</th>
<th class="px-4 py-3 font-medium">Episode</th>
<th class="px-4 py-3 font-medium text-right">Nedlastinger</th>
<th class="px-4 py-3 font-medium text-right hidden sm:table-cell"
>Unike lyttere</th
>
<th class="px-4 py-3 font-medium w-1/4 hidden md:table-cell"></th>
</tr>
</thead>
<tbody>
{#each data.episodes as ep, i}
<tr class="border-b border-neutral-800/50 hover:bg-neutral-800/30">
<td class="px-4 py-2.5 text-neutral-500 font-mono text-xs"
>{i + 1}</td
>
<td class="px-4 py-2.5">
<div class="font-medium text-neutral-200">
{ep.episode_title || 'Ukjent episode'}
</div>
{#if ep.first_date && ep.last_date}
<div class="text-xs text-neutral-500 mt-0.5">
{formatDate(ep.first_date)} {formatDate(ep.last_date)}
({ep.days_with_data} dager)
</div>
{/if}
</td>
<td class="px-4 py-2.5 text-right font-mono text-neutral-200">
{formatNumber(ep.total_downloads)}
</td>
<td
class="px-4 py-2.5 text-right font-mono text-neutral-400 hidden sm:table-cell"
>
{formatNumber(ep.total_unique_listeners)}
</td>
<td class="px-4 py-2.5 hidden md:table-cell">
<div
class="h-3 bg-blue-500/40 rounded-sm"
style="width: {barWidth(ep.total_downloads, maxEpisode())}%"
></div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</section>
{/if}
<!-- Klientfordeling -->
{#if data.clients.length > 0}
<section>
<h2 class="text-lg font-semibold mb-3">Klienter</h2>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
<!-- Stacked bar overview -->
{#if totalClientCount() > 0}
<div class="h-6 rounded-full overflow-hidden flex mb-4">
{#each data.clients as client}
{@const pct = (client.count / totalClientCount()) * 100}
{#if pct >= 1}
<div
class="{clientColor(client.client)} opacity-80"
style="width: {pct}%"
title="{client.client}: {formatNumber(client.count)} ({pct.toFixed(1)}%)"
></div>
{/if}
{/each}
</div>
{/if}
<!-- Detaljert liste -->
<div class="space-y-2">
{#each data.clients as client}
{@const pct =
totalClientCount() > 0
? (client.count / totalClientCount()) * 100
: 0}
<div class="flex items-center gap-3 text-sm">
<div
class="w-3 h-3 rounded-full shrink-0 {clientColor(client.client)}"
></div>
<span class="w-32 sm:w-40 truncate text-neutral-300">{client.client}</span>
<div class="flex-1">
<div
class="h-4 {clientColor(client.client)} opacity-40 rounded-sm"
style="width: {barWidth(client.count, maxClient())}%"
></div>
</div>
<span class="font-mono text-xs text-neutral-300 w-14 text-right">
{formatNumber(client.count)}
</span>
<span class="text-xs text-neutral-500 w-12 text-right">
{pct.toFixed(1)}%
</span>
</div>
{/each}
</div>
</div>
</section>
{/if}
<!-- Tom tilstand -->
{#if data.episodes.length === 0 && data.daily.length === 0}
<div
class="bg-neutral-900 border border-neutral-800 rounded-lg p-8 text-center text-neutral-500"
>
<p class="text-lg mb-2">Ingen nedlastingsdata</p>
<p class="text-sm">
Kjør <code class="bg-neutral-800 px-1.5 py-0.5 rounded text-neutral-400"
>synops-stats --write</code
> for å importere statistikk fra Caddy-loggene.
</p>
</div>
{/if}
{/if}
</div>
</div>

View file

@ -39,6 +39,7 @@ pub mod script_executor;
pub mod tiptap;
pub mod transcribe;
pub mod tts;
pub mod podcast_stats;
pub mod usage_overview;
pub mod user_usage;
mod workspace;
@ -251,6 +252,8 @@ async fn main() {
.route("/admin/ai/delete_routing", post(ai_admin::delete_routing))
// Forbruksoversikt (oppgave 15.8)
.route("/admin/usage", get(usage_overview::usage_overview))
// Podcast-statistikk (oppgave 30.4)
.route("/admin/podcast/stats", get(podcast_stats::podcast_stats))
// Brukersynlig forbruk (oppgave 15.9)
.route("/my/usage", get(user_usage::my_usage))
.route("/my/workspace", get(workspace::my_workspace))

View file

@ -0,0 +1,206 @@
// Podcast-statistikk dashboard API (oppgave 30.4)
//
// Leser fra podcast_download_stats-tabellen (skrevet av synops-stats CLI)
// og returnerer aggregert data for admin-dashboardet:
// - Nedlastinger per episode (totalt + trend)
// - Topp-episoder
// - Klientfordeling (Apple Podcasts, Spotify, etc.)
// - Daglig tidsserie
//
// Ref: docs/features/podcast_statistikk.md
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AdminUser;
use crate::AppState;
// =============================================================================
// Datatyper
// =============================================================================
#[derive(Serialize, sqlx::FromRow)]
pub struct EpisodeTotal {
pub episode_id: Option<Uuid>,
pub episode_title: Option<String>,
pub total_downloads: i64,
pub total_unique_listeners: i64,
pub first_date: Option<NaiveDate>,
pub last_date: Option<NaiveDate>,
pub days_with_data: i64,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct DailyDownloads {
pub date: NaiveDate,
pub downloads: i64,
pub unique_listeners: i64,
}
#[derive(Serialize)]
pub struct ClientBreakdown {
pub client: String,
pub count: i64,
}
#[derive(Serialize)]
pub struct PodcastStatsResponse {
/// Totalt over hele perioden
pub total_downloads: i64,
pub total_unique_listeners: i64,
/// Per episode, sortert etter nedlastinger (topp først)
pub episodes: Vec<EpisodeTotal>,
/// Daglig tidsserie (alle episoder aggregert)
pub daily: Vec<DailyDownloads>,
/// Klientfordeling (aggregert over perioden)
pub clients: Vec<ClientBreakdown>,
}
#[derive(Deserialize)]
pub struct PodcastStatsParams {
pub days: Option<i32>,
pub episode_id: Option<Uuid>,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
// =============================================================================
// GET /admin/podcast/stats
// =============================================================================
pub async fn podcast_stats(
State(state): State<AppState>,
_admin: AdminUser,
axum::extract::Query(params): axum::extract::Query<PodcastStatsParams>,
) -> Result<Json<PodcastStatsResponse>, (StatusCode, Json<ErrorResponse>)> {
let days = params.days.unwrap_or(30).clamp(1, 365);
let episodes = fetch_episode_totals(&state.db, days, params.episode_id)
.await
.map_err(|e| internal_error(&format!("Feil i episodeoversikt: {e}")))?;
let daily = fetch_daily(&state.db, days, params.episode_id)
.await
.map_err(|e| internal_error(&format!("Feil i daglig oversikt: {e}")))?;
let clients = fetch_clients(&state.db, days, params.episode_id)
.await
.map_err(|e| internal_error(&format!("Feil i klientoversikt: {e}")))?;
let total_downloads = episodes.iter().map(|e| e.total_downloads).sum();
let total_unique_listeners = episodes.iter().map(|e| e.total_unique_listeners).sum();
Ok(Json(PodcastStatsResponse {
total_downloads,
total_unique_listeners,
episodes,
daily,
clients,
}))
}
// =============================================================================
// Spørringer
// =============================================================================
async fn fetch_episode_totals(
db: &PgPool,
days: i32,
episode_filter: Option<Uuid>,
) -> Result<Vec<EpisodeTotal>, sqlx::Error> {
sqlx::query_as::<_, EpisodeTotal>(
r#"
SELECT
s.episode_id,
n.title AS episode_title,
COALESCE(SUM(s.downloads), 0)::BIGINT AS total_downloads,
COALESCE(SUM(s.unique_listeners), 0)::BIGINT AS total_unique_listeners,
MIN(s.date) AS first_date,
MAX(s.date) AS last_date,
COUNT(DISTINCT s.date)::BIGINT AS days_with_data
FROM podcast_download_stats s
LEFT JOIN nodes n ON n.id = s.episode_id
WHERE s.date >= (CURRENT_DATE - make_interval(days := $1))
AND ($2::UUID IS NULL OR s.episode_id = $2)
GROUP BY s.episode_id, n.title
ORDER BY total_downloads DESC
"#,
)
.bind(days)
.bind(episode_filter)
.fetch_all(db)
.await
}
async fn fetch_daily(
db: &PgPool,
days: i32,
episode_filter: Option<Uuid>,
) -> Result<Vec<DailyDownloads>, sqlx::Error> {
sqlx::query_as::<_, DailyDownloads>(
r#"
SELECT
s.date,
COALESCE(SUM(s.downloads), 0)::BIGINT AS downloads,
COALESCE(SUM(s.unique_listeners), 0)::BIGINT AS unique_listeners
FROM podcast_download_stats s
WHERE s.date >= (CURRENT_DATE - make_interval(days := $1))
AND ($2::UUID IS NULL OR s.episode_id = $2)
GROUP BY s.date
ORDER BY s.date ASC
"#,
)
.bind(days)
.bind(episode_filter)
.fetch_all(db)
.await
}
/// Aggreger klientfordeling fra JSONB clients-feltet.
async fn fetch_clients(
db: &PgPool,
days: i32,
episode_filter: Option<Uuid>,
) -> Result<Vec<ClientBreakdown>, sqlx::Error> {
// clients er JSONB med { "Apple Podcasts": 18, "Spotify": 15, ... }
// Vi bruker jsonb_each_text for å ekspandere og aggregere
let rows: Vec<(String, i64)> = sqlx::query_as(
r#"
SELECT
kv.key AS client,
COALESCE(SUM(kv.value::BIGINT), 0)::BIGINT AS count
FROM podcast_download_stats s,
jsonb_each_text(s.clients) AS kv(key, value)
WHERE s.date >= (CURRENT_DATE - make_interval(days := $1))
AND ($2::UUID IS NULL OR s.episode_id = $2)
GROUP BY kv.key
ORDER BY count DESC
"#,
)
.bind(days)
.bind(episode_filter)
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(|(client, count)| ClientBreakdown { client, count })
.collect())
}

View file

@ -427,8 +427,7 @@ prøveimport-flyt.
### Statistikk
- [x] 30.3 `synops-stats` CLI: parse Caddy access-logger for /media/*-requests. Aggreger nedlastinger per episode per dag. IAB-regler: filtrer bots (user-agent), unik IP per 24t. Output: JSON med episode_id, date, downloads, unique_listeners. `--write` lagrer i PG.
- [~] 30.4 Statistikk-dashboard: vis nedlastinger per episode, trend over tid, topp-episoder, klienter (Apple/Spotify/andre), geografi. Integrert i admin-panelet.
> Påbegynt: 2026-03-18T23:35
- [x] 30.4 Statistikk-dashboard: vis nedlastinger per episode, trend over tid, topp-episoder, klienter (Apple/Spotify/andre), geografi. Integrert i admin-panelet.
### Embed-spiller
- [ ] 30.5 Podcast-spiller komponent: Svelte-komponent med artwork, tittel, play/pause, progress, waveform, kapittelmerkering. Responsiv. Serveres som iframe-embed: `synops.no/pub/<slug>/<episode>/player`.