diff --git a/drizzle/0006_known_avengers.sql b/drizzle/0006_known_avengers.sql new file mode 100644 index 0000000..cdcc729 --- /dev/null +++ b/drizzle/0006_known_avengers.sql @@ -0,0 +1 @@ +ALTER TABLE `user` ADD `statistics_opt_out` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..eb6996a --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 12d824d..80aefd6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/lib/components/app/auth-dialog.svelte b/src/lib/components/app/auth-dialog.svelte index ae497d2..1fae4ce 100644 --- a/src/lib/components/app/auth-dialog.svelte +++ b/src/lib/components/app/auth-dialog.svelte @@ -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(null); let authScreen = $state<'login' | 'create'>('login'); let passkeyName = $state(''); + let statisticsMessage = $state(''); let turnstileToken = $state(''); let resetTurnstile = $state<(() => void) | undefined>(); let createFormElement = $state(); @@ -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 @@ +
+
+
+

Listening statistics

+

+ {user.statisticsOptOut + ? 'New listening activity is not being collected for your account.' + : 'Collect listening activity to power your account statistics.'} +

+
+ updateStatisticsOptOut(!checked)} + disabled={busyAction === 'statistics-opt-out'} + /> +
+ {#if statisticsMessage} +

{statisticsMessage}

+ {/if} +
+

Passkeys

diff --git a/src/lib/components/app/daemon.svelte b/src/lib/components/app/daemon.svelte index c2544b5..eadb3f4 100644 --- a/src/lib/components/app/daemon.svelte +++ b/src/lib/components/app/daemon.svelte @@ -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 @@ > + + appState.showStats = false} + show={appState.showStats} +> + + diff --git a/src/lib/components/app/left-bar.svelte b/src/lib/components/app/left-bar.svelte index cac04fb..e5f2b67 100644 --- a/src/lib/components/app/left-bar.svelte +++ b/src/lib/components/app/left-bar.svelte @@ -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';
+
diff --git a/src/lib/components/app/stats.svelte b/src/lib/components/app/stats.svelte new file mode 100644 index 0000000..e4d8f89 --- /dev/null +++ b/src/lib/components/app/stats.svelte @@ -0,0 +1,157 @@ + + +
+
+

Statistics

+

Listening overview

+
+ + {#if isLoading} +
+ Loading stats... +
+ {:else if error} +
+ {error} +
+ {:else if stats} +
+
+

Today

+

{formatDuration(stats.todaySeconds)}

+
+
+

All time

+

{formatDuration(stats.totalSeconds)}

+
+
+ +
+
+

Top stations

+ + + + Songs + + + Top songs + + + {#each stats.topSongs as song} + + {#if song.image} + + {:else} +
+ {/if} +
+

{song.title ?? song.fileId}

+

+ {song.artists ?? 'Unknown artist'} +

+
+ + {formatDuration(song.seconds)} + +
+ {:else} + + No top songs yet + + {/each} +
+
+
+
+ +
+ {#each stats.topStations as station} +
+ {stationName(station.stationId)} + {formatDuration(station.seconds)} +
+ {:else} +

+ Station stats will appear after you listen for a bit. +

+ {/each} +
+
+ {/if} +
diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte index 3e24cc3..3caf41e 100644 --- a/src/lib/components/ui/dialog/dialog-content.svelte +++ b/src/lib/components/ui/dialog/dialog-content.svelte @@ -23,7 +23,7 @@ -
+
{@render children?.()}
Close diff --git a/src/lib/components/ui/dialog/dialog-overlay.svelte b/src/lib/components/ui/dialog/dialog-overlay.svelte index 86e33d2..49c44e9 100644 --- a/src/lib/components/ui/dialog/dialog-overlay.svelte +++ b/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -12,7 +12,7 @@ + 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 = $props(); + + + + + diff --git a/src/lib/components/ui/window/window.svelte b/src/lib/components/ui/window/window.svelte index 679daab..7046a62 100644 --- a/src/lib/components/ui/window/window.svelte +++ b/src/lib/components/ui/window/window.svelte @@ -112,7 +112,7 @@ {#if show}