basic stats view
This commit is contained in:
parent
a386920a90
commit
dd51cd10f5
7 changed files with 279 additions and 17 deletions
|
|
@ -4,10 +4,11 @@ import { syncApp } from "./routes/sync";
|
||||||
import { DBOS } from "@dbos-inc/dbos-sdk";
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||||
import "./workflows/sync";
|
import "./workflows/sync";
|
||||||
import "./dbos.ts";
|
import "./dbos.ts";
|
||||||
|
import { statsApp } from "./routes/stats.ts";
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.use(betterAuthElysia)
|
.use(betterAuthElysia)
|
||||||
.group("/api", (app) => app.use(syncApp))
|
.group("/api", (app) => app.use(syncApp).use(statsApp))
|
||||||
.listen(4000);
|
.listen(4000);
|
||||||
|
|
||||||
export type App = typeof app;
|
export type App = typeof app;
|
||||||
|
|
|
||||||
104
api/src/routes/stats.ts
Normal file
104
api/src/routes/stats.ts
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
107
web/src/components/quick-stats.tsx
Normal file
107
web/src/components/quick-stats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,33 @@
|
||||||
import { client } from "#/lib/eden";
|
import { client } from "#/lib/eden";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Item, ItemActions, ItemDescription, ItemTitle } from "./ui/item";
|
||||||
|
|
||||||
export function SyncButton() {
|
export function SyncButton() {
|
||||||
|
const query = useQueryClient();
|
||||||
return (
|
return (
|
||||||
<Button
|
<Item className="px-0 justify-between">
|
||||||
onClick={async () => {
|
<ItemDescription>Sync your data</ItemDescription>
|
||||||
toast.promise(
|
<ItemActions>
|
||||||
client.api.sync.post().then((data) => console.log(data)),
|
<Button
|
||||||
{
|
onClick={async () => {
|
||||||
loading: "Syncing...",
|
toast.promise(
|
||||||
success: "Synced!",
|
(async () => {
|
||||||
error: "Sync failed",
|
await client.api.sync.post();
|
||||||
},
|
query.invalidateQueries();
|
||||||
);
|
})(),
|
||||||
}}
|
{
|
||||||
>
|
loading: "Syncing...",
|
||||||
Sync
|
success: "Synced!",
|
||||||
</Button>
|
error: "Sync failed",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sync
|
||||||
|
</Button>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
web/src/components/ui/main-content.tsx
Normal file
11
web/src/components/ui/main-content.tsx
Normal 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>;
|
||||||
|
}
|
||||||
25
web/src/components/ui/section.tsx
Normal file
25
web/src/components/ui/section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { QuickStats } from "#/components/quick-stats";
|
||||||
import { SyncButton } from "#/components/sync-button";
|
import { SyncButton } from "#/components/sync-button";
|
||||||
|
import { MainContent } from "#/components/ui/main-content";
|
||||||
import { UserInfo } from "#/components/user-info";
|
import { UserInfo } from "#/components/user-info";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
|
@ -6,9 +8,10 @@ export const Route = createFileRoute("/")({ component: App });
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<MainContent>
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
|
<QuickStats />
|
||||||
<SyncButton />
|
<SyncButton />
|
||||||
</main>
|
</MainContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue