diff --git a/api/src/index.ts b/api/src/index.ts index 2345dc6..e07dc4a 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -4,10 +4,11 @@ import { syncApp } from "./routes/sync"; import { DBOS } from "@dbos-inc/dbos-sdk"; import "./workflows/sync"; import "./dbos.ts"; +import { statsApp } from "./routes/stats.ts"; const app = new Elysia() .use(betterAuthElysia) - .group("/api", (app) => app.use(syncApp)) + .group("/api", (app) => app.use(syncApp).use(statsApp)) .listen(4000); export type App = typeof app; diff --git a/api/src/routes/stats.ts b/api/src/routes/stats.ts new file mode 100644 index 0000000..e45639d --- /dev/null +++ b/api/src/routes/stats.ts @@ -0,0 +1,104 @@ +import Elysia from "elysia"; +import { sql } from "drizzle-orm"; +import { betterAuthElysia } from "../auth"; +import { db } from "../db"; +import { + artistGenre, + genre, + savedTrack, + topTrack, + trackArtist, +} from "../db/schema"; + +export const statsApp = new Elysia().use(betterAuthElysia).get( + "/stats", + async ({ user }) => { + const topArtists = await db.query.topArtist.findMany({ + limit: 10, + with: { + artist: { + with: { + genres: true, + images: true, + }, + }, + }, + where: { + userId: user.id, + }, + }); + const topTracks = await db.query.topTrack.findMany({ + limit: 10, + with: { + track: { + with: { + album: { + with: { + images: true, + }, + }, + artists: { + with: { + genres: true, + }, + }, + }, + }, + }, + where: { + userId: user.id, + timeline: "medium_term", + }, + orderBy: { + position: "desc", + }, + }); + const recentTracks = await db.query.playbackHistory.findMany({ + limit: 10, + with: { + track: { + with: { + album: { + with: { + images: true, + }, + }, + }, + }, + }, + where: { + userId: user.id, + }, + }); + const topGenresResult = await db.execute<{ + name: string; + count: number; + }>(sql` + select ${genre.name} as name, count(*)::int as count + from ( + select distinct ${trackArtist.trackId} as track_id, ${artistGenre.genreId} as genre_id + from ${trackArtist} + inner join ${artistGenre} on ${artistGenre.artistId} = ${trackArtist.artistId} + inner join ( + select ${topTrack.trackId} as track_id + from ${topTrack} + where ${topTrack.userId} = ${user.id} + and ${topTrack.timeline} = 'medium_term' + union + select ${savedTrack.trackId} as track_id + from ${savedTrack} + where ${savedTrack.userId} = ${user.id} + ) as selected_tracks on selected_tracks.track_id = ${trackArtist.trackId} + ) as genre_tracks + inner join ${genre} on ${genre.id} = genre_tracks.genre_id + group by ${genre.name} + order by count desc + limit 10 + `); + const topGenres = topGenresResult.rows; + return { topArtists, topTracks, recentTracks, topGenres }; + }, + { + auth: true, + }, +); diff --git a/web/src/components/quick-stats.tsx b/web/src/components/quick-stats.tsx new file mode 100644 index 0000000..3e33a0b --- /dev/null +++ b/web/src/components/quick-stats.tsx @@ -0,0 +1,107 @@ +import { client } from "#/lib/eden"; +import { useQuery } from "@tanstack/react-query"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "#/components/ui/card"; +import { Avatar, AvatarGroup, AvatarImage } from "#/components/ui/avatar"; +import { + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemMedia, + ItemTitle, +} from "#/components/ui/item"; +import { Badge } from "#/components/ui/badge"; +import { Spinner } from "#/components/ui/spinner"; +import { Section, SectionTitle } from "./ui/section"; + +const MAX_AVATARS = 6; + +export function QuickStats() { + const { isLoading, data } = useQuery({ + queryFn: () => client.api.stats.get(), + queryKey: ["stats"], + }); + + if (isLoading) { + return ( + + + Quick stats + Loading your music highlights. + + +
+ + Fetching stats +
+
+
+ ); + } + + const topArtists = data?.data?.topArtists ?? []; + const topTracks = data?.data?.topTracks ?? []; + const topGenres = data?.data?.topGenres ?? []; + + return ( +
+ Data overview + + + + + {topArtists.slice(0, MAX_AVATARS).map((entry) => ( + + + + ))} + + + + Artists + + + + + + {topTracks.slice(0, MAX_AVATARS).map((entry) => ( + + + + ))} + + + + Tracks + + + + + Genres + + {topGenres.length === 0 + ? "No genres yet." + : topGenres.slice(0, 8).map((genre) => ( + + {genre.name} + + ))} + + + + +
+ ); +} diff --git a/web/src/components/sync-button.tsx b/web/src/components/sync-button.tsx index 85e5856..68840d8 100644 --- a/web/src/components/sync-button.tsx +++ b/web/src/components/sync-button.tsx @@ -1,22 +1,33 @@ import { client } from "#/lib/eden"; import { toast } from "sonner"; import { Button } from "./ui/button"; +import { useQueryClient } from "@tanstack/react-query"; +import { Item, ItemActions, ItemDescription, ItemTitle } from "./ui/item"; export function SyncButton() { + const query = useQueryClient(); return ( - + + Sync your data + + + + ); } diff --git a/web/src/components/ui/main-content.tsx b/web/src/components/ui/main-content.tsx new file mode 100644 index 0000000..4cb77f1 --- /dev/null +++ b/web/src/components/ui/main-content.tsx @@ -0,0 +1,11 @@ +import { cn } from "#/lib/utils"; + +export function MainContent({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return
{children}
; +} diff --git a/web/src/components/ui/section.tsx b/web/src/components/ui/section.tsx new file mode 100644 index 0000000..33508cc --- /dev/null +++ b/web/src/components/ui/section.tsx @@ -0,0 +1,25 @@ +import { cn } from "#/lib/utils"; + +export function Section({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { + return
{children}
; +} + +export function SectionTitle({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +

+ {children} +

+ ); +} diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index c9bf3ab..c598138 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -1,4 +1,6 @@ +import { QuickStats } from "#/components/quick-stats"; import { SyncButton } from "#/components/sync-button"; +import { MainContent } from "#/components/ui/main-content"; import { UserInfo } from "#/components/user-info"; import { createFileRoute } from "@tanstack/react-router"; @@ -6,9 +8,10 @@ export const Route = createFileRoute("/")({ component: App }); function App() { return ( -
+ + -
+ ); }