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 (
+