feat: listening stats!!!!

This commit is contained in:
2026-04-24 17:41:37 +02:00
parent 59e82142d5
commit 03c871e94b
18 changed files with 1005 additions and 6 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `statistics_opt_out` integer DEFAULT false NOT NULL;

View File

@@ -0,0 +1,687 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ec11112d-e50d-4aff-9914-e3d270cc2f5a",
"prevId": "9e75c298-5f62-44f0-9f1a-40481e7df553",
"tables": {
"song_ids": {
"name": "song_ids",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"file_id": {
"name": "file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"spotify_id": {
"name": "spotify_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"artists": {
"name": "artists",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"song_ids_file_id_idx": {
"name": "song_ids_file_id_idx",
"columns": [
"file_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stat_bucket": {
"name": "user_stat_bucket",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metric": {
"name": "metric",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bucket_start": {
"name": "bucket_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"station_id": {
"name": "station_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"file_id": {
"name": "file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_stat_bucket_user_metric_bucket_idx": {
"name": "user_stat_bucket_user_metric_bucket_idx",
"columns": [
"user_id",
"metric",
"bucket_start"
],
"isUnique": false
},
"user_stat_bucket_unique_idx": {
"name": "user_stat_bucket_unique_idx",
"columns": [
"user_id",
"metric",
"bucket_start",
"station_id",
"file_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_stat_bucket_user_id_user_id_fk": {
"name": "user_stat_bucket_user_id_user_id_fk",
"tableFrom": "user_stat_bucket",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"passkey": {
"name": "passkey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credential_id": {
"name": "credential_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_type": {
"name": "device_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"backed_up": {
"name": "backed_up",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"aaguid": {
"name": "aaguid",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"passkey_userId_idx": {
"name": "passkey_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"passkey_credentialID_idx": {
"name": "passkey_credentialID_idx",
"columns": [
"credential_id"
],
"isUnique": false
}
},
"foreignKeys": {
"passkey_user_id_user_id_fk": {
"name": "passkey_user_id_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"is_anonymous": {
"name": "is_anonymous",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"account_number": {
"name": "account_number",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"statistics_opt_out": {
"name": "statistics_opt_out",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_account_number_unique": {
"name": "user_account_number_unique",
"columns": [
"account_number"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -43,6 +43,13 @@
"when": 1777011840981,
"tag": "0005_chubby_master_mold",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1777041984571,
"tag": "0006_known_avengers",
"breakpoints": true
}
]
}

View File

@@ -16,6 +16,7 @@
import { Turnstile } from 'svelte-turnstile';
import { dev } from '$app/environment';
import { env as publicEnv } from '$env/dynamic/public';
import Switch from '../ui/switch/switch.svelte';
const session = authClient.useSession();
@@ -30,6 +31,7 @@
let loadedPasskeysForUserId = $state<string | null>(null);
let authScreen = $state<'login' | 'create'>('login');
let passkeyName = $state('');
let statisticsMessage = $state('');
let turnstileToken = $state('');
let resetTurnstile = $state<(() => void) | undefined>();
let createFormElement = $state<HTMLDivElement>();
@@ -161,6 +163,28 @@
const signOut = () => runAuthAction('sign-out', () => authClient.signOut(), 'Sign out failed');
const updateStatisticsOptOut = async (statisticsOptOut: boolean) => {
busyAction = 'statistics-opt-out';
statisticsMessage = '';
const response = await fetch('/api/account/statistics-opt-out', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ statisticsOptOut }),
});
busyAction = null;
if (!response.ok) {
statisticsMessage = 'Failed to update statistics preference';
return;
}
await session.get().refetch();
statisticsMessage = statisticsOptOut
? 'Statistics collection is turned off.'
: 'Statistics collection is turned on.';
};
const addPasskey = async () => {
if (!passkeyName) {
passkeyMessage = 'Please enter a name for your passkey';
@@ -243,6 +267,28 @@
</div>
</div>
<div class="space-y-3 p-4 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0 space-y-1">
<h3 class="text-sm font-medium text-white/80">Listening statistics</h3>
<p class="text-xs leading-relaxed text-white/45">
{user.statisticsOptOut
? 'New listening activity is not being collected for your account.'
: 'Collect listening activity to power your account statistics.'}
</p>
</div>
<Switch
id="statisticsCollection"
checked={!user.statisticsOptOut}
onCheckedChange={(checked) => updateStatisticsOptOut(!checked)}
disabled={busyAction === 'statistics-opt-out'}
/>
</div>
{#if statisticsMessage}
<p class="text-xs text-white/60">{statisticsMessage}</p>
{/if}
</div>
<div class="space-y-3">
<h3 class="text-sm font-medium text-white/80">Passkeys</h3>

View File

@@ -7,6 +7,11 @@
import TodoList from './todo-list.svelte';
import Twentytwentytwenty from './twentytwentytwenty.svelte';
import Pomodoro from './pomodoro.svelte';
import Stats from './stats.svelte';
import { authClient } from '$lib';
const session = authClient.useSession();
const user = $derived($session.data?.user);
// svelte-ignore non_reactive_update
let audioElement: HTMLAudioElement;
@@ -167,6 +172,7 @@
!appState.isPlaying ||
!appState.currentSong ||
!appState.currentStation ||
user?.statisticsOptOut ||
!audioElement ||
audioElement.paused
) {
@@ -300,3 +306,15 @@
>
<Pomodoro></Pomodoro>
</Window>
<Window
title="Stats"
showTitleBar={true}
showCloseButton={true}
width={500}
height={400}
onClose={() => appState.showStats = false}
show={appState.showStats}
>
<Stats></Stats>
</Window>

View File

@@ -4,6 +4,7 @@
import Button from '../ui/button/button.svelte';
import Binoculars from '@lucide/svelte/icons/binoculars';
import Clock from '@lucide/svelte/icons/clock';
import ChartNoAxesColumn from '@lucide/svelte/icons/chart-no-axes-column';
</script>
<div
@@ -24,4 +25,10 @@
>
<Clock class="size-4" />
</Button>
<Button
size="icon"
onclick={() => (appState.showStats = !appState.showStats)}
>
<ChartNoAxesColumn class="size-4" />
</Button>
</div>

View File

@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from 'svelte';
import { state as appState } from '@/state.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import ListMusic from '@lucide/svelte/icons/list-music';
type TopSong = {
fileId: string;
title: string | null;
artists: string | null;
image: string | null;
seconds: number;
};
type TopStation = {
stationId: number | null;
seconds: number;
};
type StatsResponse = {
totalSeconds: number;
todaySeconds: number;
topSongs: TopSong[];
topStations: TopStation[];
};
let stats = $state<StatsResponse | null>(null);
let isLoading = $state(true);
let error = $state<string | null>(null);
const formatDuration = (seconds: number) => {
const totalMinutes = Math.floor(seconds / 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) return `${minutes}m`;
if (minutes === 0) return `${hours}h`;
return `${hours}h ${minutes}m`;
};
const stationName = (stationId: number | null) => {
const station = appState.stations.find((item) => item.id === stationId);
return station?.name ?? 'Unknown station';
};
onMount(async () => {
try {
const response = await fetch('/api/stats');
if (!response.ok) {
error =
response.status === 401
? 'Sign in to see your listening stats.'
: 'Could not load stats.';
return;
}
stats = await response.json();
} catch {
error = 'Could not load stats.';
} finally {
isLoading = false;
}
});
</script>
<div class="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-4 text-foreground">
<div>
<p class="text-xs uppercase tracking-[0.2em] text-foreground/50">Statistics</p>
<h2 class="text-2xl font-semibold leading-tight">Listening overview</h2>
</div>
{#if isLoading}
<div class="rounded-lg border border-white/10 bg-white/10 p-4 text-sm text-foreground/70">
Loading stats...
</div>
{:else if error}
<div class="rounded-lg border border-white/10 bg-white/10 p-4 text-sm text-foreground/70">
{error}
</div>
{:else if stats}
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg border border-white/10 bg-white/10 p-3">
<p class="text-xs uppercase tracking-[0.16em] text-foreground/50">Today</p>
<p class="mt-1 text-2xl font-semibold">{formatDuration(stats.todaySeconds)}</p>
</div>
<div class="rounded-lg border border-white/10 bg-white/10 p-3">
<p class="text-xs uppercase tracking-[0.16em] text-foreground/50">All time</p>
<p class="mt-1 text-2xl font-semibold">{formatDuration(stats.totalSeconds)}</p>
</div>
</div>
<section class="flex min-h-0 flex-1 flex-col">
<div class="mb-2 flex items-center justify-between gap-3">
<h3 class="text-sm font-medium text-foreground/80">Top stations</h3>
<DropdownMenu.Root>
<DropdownMenu.Trigger
class={buttonVariants({
variant: 'default',
size: 'sm',
class: 'h-7 px-2 text-xs'
})}
aria-label="Show top songs"
>
<ListMusic class="size-3.5" />
Songs
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-72 max-h-72 overflow-y-auto" align="end">
<DropdownMenu.Label>Top songs</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
{#each stats.topSongs as song}
<DropdownMenu.Item class="gap-3 p-2">
{#if song.image}
<img src={song.image} alt="" class="size-9 shrink-0 rounded-md object-cover" />
{:else}
<div class="size-9 shrink-0 rounded-md bg-white/10"></div>
{/if}
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{song.title ?? song.fileId}</p>
<p class="truncate text-xs text-foreground/55">
{song.artists ?? 'Unknown artist'}
</p>
</div>
<span class="shrink-0 text-xs text-foreground/60">
{formatDuration(song.seconds)}
</span>
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item disabled class="text-foreground/60">
No top songs yet
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
<div class="min-h-0 space-y-2 overflow-y-auto pr-1">
{#each stats.topStations as station}
<div
class="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/10 px-3 py-2 text-sm"
>
<span class="min-w-0 truncate">{stationName(station.stationId)}</span>
<span class="shrink-0 text-foreground/60">{formatDuration(station.seconds)}</span>
</div>
{:else}
<p class="rounded-lg border border-white/10 bg-white/10 p-3 text-sm text-foreground/60">
Station stats will appear after you listen for a bit.
</p>
{/each}
</div>
</section>
{/if}
</div>

View File

@@ -23,7 +23,7 @@
<DialogPrimitive.Content
bind:ref
class={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 p-6 duration-300 sm:rounded-2xl',
'fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 p-6 duration-300 sm:rounded-2xl',
'bg-white/[0.08] dark:bg-black/[0.45] backdrop-blur-2xl',
'border border-white/[0.12] dark:border-white/[0.08]',
'shadow-[0_8px_32px_rgba(0,0,0,0.32),inset_0_1px_0_rgba(255,255,255,0.1)]',
@@ -38,11 +38,11 @@
)}
{...restProps}
>
<div class="relative z-10 text-white max-w-none space-y-3 font-medium">
<div class="relative z-[60] text-white max-w-none space-y-3 font-medium">
{@render children?.()}
</div>
<DialogPrimitive.Close
class="absolute right-4 top-4 z-20 rounded-lg p-2 opacity-60 transition-all duration-200 hover:opacity-100 hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white/20 disabled:pointer-events-none backdrop-blur-sm border border-white/[0.08]"
class="absolute right-4 top-4 z-[60] rounded-lg p-2 opacity-60 transition-all duration-200 hover:opacity-100 hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white/20 disabled:pointer-events-none backdrop-blur-sm border border-white/[0.08]"
>
<X class="size-4 text-white" />
<span class="sr-only">Close</span>

View File

@@ -12,7 +12,7 @@
<DialogPrimitive.Overlay
bind:ref
class={cn(
"fixed inset-0 z-50",
"fixed inset-0 z-[60]",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"bg-black/40 backdrop-blur-sm",

View File

@@ -18,7 +18,7 @@
bind:ref
{sideOffset}
class={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
'bg-popover text-popover-foreground z-[100] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none',
'bg-white/10 backdrop-blur-md transition hover:bg-white/15 text-foreground border border-white/20 shadow-lg',
className

View File

@@ -0,0 +1,7 @@
import Root from "./switch.svelte";
export {
Root,
//
Root as Switch,
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
class={cn(
"focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
class={cn(
"bg-background pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>

View File

@@ -112,7 +112,7 @@
{#if show}
<div
bind:this={windowRef}
class="fixed flex flex-col bg-white/10 backdrop-blur-md border border-white/20 shadow-lg rounded-lg overflow-hidden"
class="fixed flex flex-col bg-foreground/20 backdrop-blur-md border border-foreground/20 shadow-lg rounded-lg overflow-hidden"
style="width: {width}px; height: {height}px; left: {x}px; top: {y}px; z-index: {zIndex};"
onmousedown={handleMouseDown}
role="dialog"

View File

@@ -187,6 +187,13 @@ const authConfig = {
fieldName: 'account_number',
defaultValue: generateAccountNumber,
},
statisticsOptOut: {
type: 'boolean',
required: true,
input: false,
fieldName: 'statistics_opt_out',
defaultValue: false,
},
},
},
logger: {

View File

@@ -18,6 +18,7 @@ export const user = sqliteTable("user", {
.notNull(),
isAnonymous: integer("is_anonymous", { mode: "boolean" }).default(false),
account_number: text("account_number").notNull().unique(),
statisticsOptOut: integer("statistics_opt_out", { mode: "boolean" }).default(false).notNull(),
});
export const session = sqliteTable(

View File

@@ -37,6 +37,8 @@ export const state = $state({
isPomodoroActive: false,
pomodoroWorkPhase: true,
showStats: false,
// in daemon.svelte
togglePlay: (() => {}) as () => void,
});

View File

@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { getDb } from '@/server/db';
import { user as userTable } from '@/server/db/schema';
import type { RequestHandler } from './$types';
type StatisticsOptOutBody = {
statisticsOptOut?: unknown;
};
export const PATCH: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const body = (await event.request.json().catch(() => ({}))) as StatisticsOptOutBody;
if (typeof body.statisticsOptOut !== 'boolean') {
return json({ error: 'Invalid statistics preference' }, { status: 400 });
}
await getDb(event.platform!.env.DB)
.update(userTable)
.set({ statisticsOptOut: body.statisticsOptOut, updatedAt: new Date() })
.where(eq(userTable.id, user.id));
return json({ statisticsOptOut: body.statisticsOptOut });
};

View File

@@ -32,6 +32,10 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (user.statisticsOptOut) {
return json({ ok: true, skipped: true });
}
const body = (await event.request.json().catch(() => ({}))) as ListenBody;
const fileId = typeof body.fileId === 'string' ? body.fileId.trim() : '';
const stationId = typeof body.stationId === 'number' ? body.stationId : Number(body.stationId);