basic stats view

This commit is contained in:
Daniel Bulant 2026-04-20 19:12:17 +02:00
parent a386920a90
commit dd51cd10f5
No known key found for this signature in database
7 changed files with 279 additions and 17 deletions

View file

@ -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;

104
api/src/routes/stats.ts Normal file
View file

@ -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,
},
);

View file

@ -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 (
<Card size="sm">
<CardHeader>
<CardTitle>Quick stats</CardTitle>
<CardDescription>Loading your music highlights.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-muted-foreground">
<Spinner />
Fetching stats
</div>
</CardContent>
</Card>
);
}
const topArtists = data?.data?.topArtists ?? [];
const topTracks = data?.data?.topTracks ?? [];
const topGenres = data?.data?.topGenres ?? [];
return (
<Section>
<SectionTitle>Data overview</SectionTitle>
<ItemGroup>
<Item size="sm" variant="muted">
<ItemMedia>
<AvatarGroup>
{topArtists.slice(0, MAX_AVATARS).map((entry) => (
<Avatar key={entry.artistId} size="sm">
<AvatarImage
src={entry.artist?.images?.[0]?.url || undefined}
alt={entry.artist?.name || ""}
/>
</Avatar>
))}
</AvatarGroup>
</ItemMedia>
<ItemContent>
<ItemTitle>Artists</ItemTitle>
</ItemContent>
</Item>
<Item size="sm" variant="muted">
<ItemMedia>
<AvatarGroup>
{topTracks.slice(0, MAX_AVATARS).map((entry) => (
<Avatar key={entry.trackId} size="sm">
<AvatarImage
src={entry.track?.album?.images?.[0]?.url || undefined}
alt={entry.track?.name || ""}
/>
</Avatar>
))}
</AvatarGroup>
</ItemMedia>
<ItemContent>
<ItemTitle>Tracks</ItemTitle>
</ItemContent>
</Item>
<Item size="sm" variant="muted">
<ItemContent>
<ItemTitle>Genres</ItemTitle>
<ItemDescription className="flex flex-wrap gap-2">
{topGenres.length === 0
? "No genres yet."
: topGenres.slice(0, 8).map((genre) => (
<Badge key={genre.name} variant="outline">
{genre.name}
</Badge>
))}
</ItemDescription>
</ItemContent>
</Item>
</ItemGroup>
</Section>
);
}

View file

@ -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 (
<Button
onClick={async () => {
toast.promise(
client.api.sync.post().then((data) => console.log(data)),
{
loading: "Syncing...",
success: "Synced!",
error: "Sync failed",
},
);
}}
>
Sync
</Button>
<Item className="px-0 justify-between">
<ItemDescription>Sync your data</ItemDescription>
<ItemActions>
<Button
onClick={async () => {
toast.promise(
(async () => {
await client.api.sync.post();
query.invalidateQueries();
})(),
{
loading: "Syncing...",
success: "Synced!",
error: "Sync failed",
},
);
}}
>
Sync
</Button>
</ItemActions>
</Item>
);
}

View file

@ -0,0 +1,11 @@
import { cn } from "#/lib/utils";
export function MainContent({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <main className={cn("min-h-screen p-8", className)}>{children}</main>;
}

View file

@ -0,0 +1,25 @@
import { cn } from "#/lib/utils";
export function Section({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return <section className={cn("", className)}>{children}</section>;
}
export function SectionTitle({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<h2 className={cn("font-heading text-base font-medium", className)}>
{children}
</h2>
);
}

View file

@ -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 (
<main>
<MainContent>
<UserInfo />
<QuickStats />
<SyncButton />
</main>
</MainContent>
);
}