Teal.fm frontend powered by slices.network tealfm-slices.wisp.place
tealfm slices

feat: overall statistics on user profiles (oops)

+1
_redirects
···
+
/* /index.html 200
+1 -1
package.json
···
"type": "module",
"scripts": {
"dev": "vite",
-
"build": "tsc -b && vite build",
+
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"schema:dev": "npx get-graphql-schema 'http://localhost:3000/graphql?slice=at://did:plc:n2sgrmrxjell7f5oa5ruwlyl/network.slices.slice/3m5d5dfs3oy26' > schema.graphql",
+61
src/ArtistItem.tsx
···
+
interface Artist {
+
artistName: string;
+
}
+
+
interface ArtistItemProps {
+
artists: string | null | undefined;
+
count: number;
+
rank: number;
+
maxCount: number;
+
}
+
+
export default function ArtistItem({
+
artists,
+
count,
+
rank,
+
maxCount,
+
}: ArtistItemProps) {
+
const barWidth = maxCount > 0 ? (count / maxCount) * 100 : 0;
+
+
// Parse artists JSON
+
let artistNames = "Unknown Artist";
+
if (artists) {
+
try {
+
const parsed = typeof artists === 'string' ? JSON.parse(artists) : artists;
+
if (Array.isArray(parsed)) {
+
artistNames = parsed.map((a: Artist) => a.artistName).join(", ");
+
} else if (typeof parsed === 'string') {
+
artistNames = parsed;
+
}
+
} catch (e) {
+
console.log('Failed to parse artists:', artists, e);
+
artistNames = String(artists);
+
}
+
}
+
+
return (
+
<div className="group py-3 px-4 hover:bg-zinc-900/50 transition-colors relative overflow-hidden">
+
<div
+
className="absolute inset-y-0 left-0 bg-violet-500/10 transition-all"
+
style={{ width: `${barWidth}%` }}
+
/>
+
<div className="flex items-center gap-4 relative">
+
<div className="text-xs text-zinc-600 w-8 text-right flex-shrink-0 font-medium">
+
{rank}
+
</div>
+
+
<div className="flex-1 min-w-0">
+
<h3 className="text-sm font-medium text-zinc-100 truncate">
+
{artistNames}
+
</h3>
+
</div>
+
+
<div className="text-right flex-shrink-0">
+
<p className="text-xs text-zinc-400 font-medium">
+
{count.toLocaleString()}
+
</p>
+
</div>
+
</div>
+
</div>
+
);
+
}
+78
src/Overall.tsx
···
+
import { NavLink, Outlet, useLocation, useParams } from "react-router-dom";
+
import ProfileLayout from "./ProfileLayout";
+
+
const periods = [
+
{ id: "daily", label: "24 hours" },
+
{ id: "weekly", label: "7 days" },
+
{ id: "monthly", label: "30 days" },
+
{ id: "all", label: "All time" },
+
];
+
+
export default function Overall() {
+
const { handle, period = "all" } = useParams<
+
{ handle: string; period?: string }
+
>();
+
const location = useLocation();
+
+
const activeTab = location.pathname.split("/")[4] || "artists";
+
+
return (
+
//@ts-expect-error: idk
+
<ProfileLayout handle={handle!}>
+
<div className="flex items-center justify-between mb-8">
+
<div className="flex items-center border-b border-zinc-800">
+
<NavLink
+
to={`/profile/${handle}/overall/artists/${period}`}
+
className={({ isActive }) =>
+
`px-4 py-2 text-xs uppercase tracking-wider ${
+
isActive
+
? "text-zinc-100 border-b-2 border-zinc-100"
+
: "text-zinc-500 hover:text-zinc-300"
+
}`}
+
>
+
Artists
+
</NavLink>
+
<NavLink
+
to={`/profile/${handle}/overall/albums/${period}`}
+
className={({ isActive }) =>
+
`px-4 py-2 text-xs uppercase tracking-wider ${
+
isActive
+
? "text-zinc-100 border-b-2 border-zinc-100"
+
: "text-zinc-500 hover:text-zinc-300"
+
}`}
+
>
+
Albums
+
</NavLink>
+
<NavLink
+
to={`/profile/${handle}/overall/tracks/${period}`}
+
className={({ isActive }) =>
+
`px-4 py-2 text-xs uppercase tracking-wider ${
+
isActive
+
? "text-zinc-100 border-b-2 border-zinc-100"
+
: "text-zinc-500 hover:text-zinc-300"
+
}`}
+
>
+
Tracks
+
</NavLink>
+
</div>
+
<div className="flex items-center gap-2">
+
{periods.map((p) => (
+
<NavLink
+
key={p.id}
+
to={`/profile/${handle}/overall/${activeTab}/${p.id}`}
+
className={() =>
+
`px-3 py-1 text-xs rounded-md ${
+
period === p.id
+
? "bg-zinc-800 text-zinc-100"
+
: "text-zinc-500 hover:bg-zinc-800/50"
+
}`}
+
>
+
{p.label}
+
</NavLink>
+
))}
+
</div>
+
</div>
+
<Outlet />
+
</ProfileLayout>
+
);
+
}
+41 -83
src/Profile.tsx
···
import { graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay";
-
import { useParams, Link } from "react-router-dom";
-
import { useEffect, useRef, useMemo } from "react";
+
import { useParams } from "react-router-dom";
+
import { useEffect, useMemo, useRef } from "react";
import type { ProfileQuery as ProfileQueryType } from "./__generated__/ProfileQuery.graphql";
import type { Profile_plays$key } from "./__generated__/Profile_plays.graphql";
import TrackItem from "./TrackItem";
-
import ScrobbleChart from "./ScrobbleChart";
+
import ProfileLayout from "./ProfileLayout";
export default function Profile() {
const { handle } = useParams<{ handle: string }>();
const queryVariables = useMemo(() => {
-
// Round to start of day to keep timestamp stable
-
const now = new Date();
-
now.setHours(0, 0, 0, 0);
-
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
-
return {
where: { actorHandle: { eq: handle } },
-
chartWhere: {
-
actorHandle: { eq: handle },
-
playedTime: {
-
gte: ninetyDaysAgo.toISOString(),
-
},
-
},
};
}, [handle]);
const queryData = useLazyLoadQuery<ProfileQueryType>(
graphql`
-
query ProfileQuery($where: FmTealAlphaFeedPlayWhereInput!, $chartWhere: FmTealAlphaFeedPlayWhereInput!) {
+
query ProfileQuery($where: FmTealAlphaFeedPlayWhereInput!) {
...Profile_plays @arguments(where: $where)
-
...ScrobbleChart_data
}
`,
-
queryVariables
+
queryVariables,
);
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment<
···
edges {
node {
...TrackItem_play
-
actorHandle
-
appBskyActorProfile {
-
displayName
-
description
-
avatar {
-
url(preset: "avatar")
-
}
-
}
}
}
}
}
`,
-
queryData
+
queryData,
);
const loadMoreRef = useRef<HTMLDivElement>(null);
const plays = useMemo(
-
() => data?.fmTealAlphaFeedPlays?.edges?.map((edge) => edge.node).filter((n) => n != null) || [],
-
[data?.fmTealAlphaFeedPlays?.edges]
+
() =>
+
data?.fmTealAlphaFeedPlays?.edges?.map((edge) => edge.node).filter((n) =>
+
n != null
+
) || [],
+
[data?.fmTealAlphaFeedPlays?.edges],
);
-
const profile = plays?.[0]?.appBskyActorProfile;
useEffect(() => {
window.scrollTo(0, 0);
···
loadNext(20);
}
},
-
{ threshold: 0.1 }
+
{ threshold: 0.1 },
);
observer.observe(loadMoreRef.current);
···
}, [hasNext, isLoadingNext, loadNext]);
return (
-
<div className="min-h-screen bg-zinc-950 text-zinc-300 font-mono">
-
<div className="max-w-4xl mx-auto px-6 py-12">
-
<Link
-
to="/"
-
className="px-2 py-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors inline-block mb-8"
-
>
-
← Back
-
</Link>
-
-
<div className="mb-12 border-b border-zinc-800 pb-6 relative">
-
<div className="absolute inset-0 pointer-events-none opacity-40">
-
<ScrobbleChart queryRef={queryData} />
-
</div>
-
<div className="relative flex items-start gap-6">
-
{profile?.avatar?.url && (
-
<img
-
src={profile.avatar.url}
-
alt={profile.displayName ?? handle ?? "User"}
-
className="w-16 h-16 flex-shrink-0 object-cover"
-
/>
-
)}
-
<div className="flex-1">
-
<h1 className="text-lg font-medium mb-1 text-zinc-100">
-
{profile?.displayName ?? handle}
-
</h1>
-
<p className="text-xs text-zinc-500 mb-2">@{handle}</p>
-
{profile?.description && (
-
<p className="text-xs text-zinc-400">{profile.description}</p>
-
)}
-
</div>
-
</div>
-
</div>
+
//@ts-expect-error: idk
+
<ProfileLayout handle={handle!}>
+
<div className="mb-8">
+
<p className="text-xs text-zinc-500 uppercase tracking-wider">
+
{(data?.fmTealAlphaFeedPlays?.totalCount ?? 0).toLocaleString()}{" "}
+
scrobbles
+
</p>
+
</div>
-
<div className="mb-8">
-
<h2 className="text-sm font-medium uppercase tracking-wider text-zinc-400 mb-2">Recent Tracks</h2>
-
<p className="text-xs text-zinc-500 uppercase tracking-wider">
-
{(data?.fmTealAlphaFeedPlays?.totalCount ?? 0).toLocaleString()} scrobbles
-
</p>
-
</div>
-
-
<div className="space-y-1">
-
{plays && plays.length > 0 ? (
+
<div className="space-y-1">
+
{plays && plays.length > 0
+
? (
plays.map((play, index) => <TrackItem key={index} play={play} />)
-
) : (
+
)
+
: (
<p className="text-zinc-600 text-center py-8 text-xs uppercase tracking-wider">
No tracks found for this user
</p>
)}
-
</div>
+
</div>
-
{hasNext && (
-
<div ref={loadMoreRef} className="py-12 text-center">
-
{isLoadingNext ? (
-
<p className="text-xs text-zinc-600 uppercase tracking-wider">Loading...</p>
-
) : (
-
<p className="text-xs text-zinc-700 uppercase tracking-wider">·</p>
+
{hasNext && (
+
<div ref={loadMoreRef} className="py-12 text-center">
+
{isLoadingNext
+
? (
+
<p className="text-xs text-zinc-600 uppercase tracking-wider">
+
Loading...
+
</p>
+
)
+
: (
+
<p className="text-xs text-zinc-700 uppercase tracking-wider">
+
·
+
</p>
)}
-
</div>
-
)}
-
</div>
-
</div>
+
</div>
+
)}
+
</ProfileLayout>
);
}
+117
src/ProfileLayout.tsx
···
+
import { graphql, useLazyLoadQuery } from "react-relay";
+
import { Link, NavLink, useParams } from "react-router-dom";
+
import { useMemo, type PropsWithChildren } from "react";
+
import type { ProfileLayoutQuery as ProfileLayoutQueryType } from "./__generated__/ProfileLayoutQuery.graphql";
+
import ScrobbleChart from "./ScrobbleChart";
+
+
export default function ProfileLayout({ children }: PropsWithChildren) {
+
const { handle } = useParams<{ handle: string }>();
+
+
const queryVariables = useMemo(() => {
+
// Round to start of day to keep timestamp stable
+
const now = new Date();
+
now.setHours(0, 0, 0, 0);
+
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
+
+
return {
+
where: { actorHandle: { eq: handle } },
+
chartWhere: {
+
actorHandle: { eq: handle },
+
playedTime: {
+
gte: ninetyDaysAgo.toISOString(),
+
},
+
},
+
};
+
}, [handle]);
+
+
const queryData = useLazyLoadQuery<ProfileLayoutQueryType>(
+
graphql`
+
query ProfileLayoutQuery($where: FmTealAlphaFeedPlayWhereInput!, $chartWhere: FmTealAlphaFeedPlayWhereInput!) {
+
...ScrobbleChart_data
+
fmTealAlphaFeedPlays(
+
first: 1
+
sortBy: [{ field: playedTime, direction: desc }]
+
where: $where
+
) {
+
edges {
+
node {
+
actorHandle
+
appBskyActorProfile {
+
displayName
+
description
+
avatar {
+
url(preset: "avatar")
+
}
+
}
+
}
+
}
+
}
+
}
+
`,
+
queryVariables
+
);
+
+
const profile = queryData?.fmTealAlphaFeedPlays?.edges?.[0]?.node?.appBskyActorProfile;
+
+
return (
+
<div className="min-h-screen bg-zinc-950 text-zinc-300 font-mono">
+
<div className="max-w-4xl mx-auto px-6 py-12">
+
<Link
+
to="/"
+
className="px-2 py-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors inline-block mb-8"
+
>
+
← Back
+
</Link>
+
+
<div className="mb-12 border-b border-zinc-800 pb-6 relative">
+
<div className="absolute inset-0 pointer-events-none opacity-40">
+
<ScrobbleChart queryRef={queryData} />
+
</div>
+
<div className="relative flex items-start gap-6">
+
{profile?.avatar?.url && (
+
<img
+
src={profile.avatar.url}
+
alt={profile.displayName ?? handle ?? "User"}
+
className="w-16 h-16 flex-shrink-0 object-cover"
+
/>
+
)}
+
<div className="flex-1">
+
<h1 className="text-lg font-medium mb-1 text-zinc-100">
+
{profile?.displayName ?? handle}
+
</h1>
+
<p className="text-xs text-zinc-500 mb-2">@{handle}</p>
+
{profile?.description && (
+
<p className="text-xs text-zinc-400">{profile.description}</p>
+
)}
+
</div>
+
</div>
+
</div>
+
+
<div className="flex items-center border-b border-zinc-800 mb-8">
+
<NavLink
+
to={`/profile/${handle}/scrobbles`}
+
className={({ isActive }) =>
+
`px-4 py-2 text-xs uppercase tracking-wider ${
+
isActive ? "text-zinc-100 border-b-2 border-zinc-100" : "text-zinc-500 hover:text-zinc-300"
+
}`
+
}
+
>
+
Scrobbles
+
</NavLink>
+
<NavLink
+
to={`/profile/${handle}/overall`}
+
className={({ isActive }) =>
+
`px-4 py-2 text-xs uppercase tracking-wider ${
+
isActive ? "text-zinc-100 border-b-2 border-zinc-100" : "text-zinc-500 hover:text-zinc-300"
+
}`
+
}
+
>
+
Overall
+
</NavLink>
+
</div>
+
+
{children}
+
</div>
+
</div>
+
);
+
}
+82
src/ProfileTopAlbums.tsx
···
+
import { graphql, useLazyLoadQuery } from "react-relay";
+
import { useParams } from "react-router-dom";
+
import type { ProfileTopAlbumsQuery } from "./__generated__/ProfileTopAlbumsQuery.graphql";
+
import AlbumItem from "./AlbumItem";
+
import { useDateRangeFilter } from "./useDateRangeFilter";
+
import { useMemo } from "react";
+
+
export default function ProfileTopAlbums() {
+
const { handle, period } = useParams<{ handle: string; period?: string }>();
+
const dateRangeVariables = useDateRangeFilter(period);
+
+
const queryVariables = useMemo(() => {
+
return {
+
where: {
+
...dateRangeVariables.where,
+
actorHandle: { eq: handle },
+
},
+
};
+
}, [handle, dateRangeVariables]);
+
+
const data = useLazyLoadQuery<ProfileTopAlbumsQuery>(
+
graphql`
+
query ProfileTopAlbumsQuery($where: FmTealAlphaFeedPlayWhereInput) {
+
fmTealAlphaFeedPlaysAggregated(
+
groupBy: [{ field: releaseMbId }, { field: releaseName }, { field: artists }]
+
orderBy: { count: desc }
+
limit: 50
+
where: $where
+
) {
+
releaseMbId
+
releaseName
+
artists
+
count
+
}
+
}
+
`,
+
queryVariables,
+
{ fetchKey: `${handle}-${period || "all"}`, fetchPolicy: "store-or-network" }
+
);
+
+
const albums = [...(data.fmTealAlphaFeedPlaysAggregated || [])];
+
+
// Deduplicate by release name, keeping the one with highest count
+
// Prefer entries with artist data
+
const seenNames = new Set<string>();
+
const dedupedAlbums = albums
+
.sort((a, b) => {
+
// First sort by count (already sorted from query)
+
if (b.count !== a.count) return b.count - a.count;
+
// Then prefer entries with artists data
+
if (a.artists && !b.artists) return -1;
+
if (!a.artists && b.artists) return 1;
+
return 0;
+
})
+
.filter((album) => {
+
const name = album.releaseName || "Unknown Album";
+
if (seenNames.has(name)) {
+
return false;
+
}
+
seenNames.add(name);
+
return true;
+
})
+
.slice(0, 10);
+
+
const maxCount = dedupedAlbums.length > 0 ? dedupedAlbums[0].count : 0;
+
+
return (
+
<div className="space-y-1">
+
{dedupedAlbums.map((album, index) => (
+
<AlbumItem
+
key={album.releaseMbId || index}
+
releaseName={album.releaseName || "Unknown Album"}
+
releaseMbId={album.releaseMbId}
+
artists={album.artists}
+
count={album.count}
+
rank={index + 1}
+
maxCount={maxCount}
+
/>
+
))}
+
</div>
+
);
+
}
+89
src/ProfileTopArtists.tsx
···
+
import { graphql, useLazyLoadQuery } from "react-relay";
+
import { useParams } from "react-router-dom";
+
import type { ProfileTopArtistsQuery } from "./__generated__/ProfileTopArtistsQuery.graphql";
+
import { useDateRangeFilter } from "./useDateRangeFilter";
+
import ArtistItem from "./ArtistItem";
+
import { useMemo } from "react";
+
+
export default function ProfileTopArtists() {
+
const { handle, period } = useParams<{ handle: string; period?: string }>();
+
const dateRangeVariables = useDateRangeFilter(period);
+
+
const queryVariables = useMemo(() => {
+
return {
+
where: {
+
...dateRangeVariables.where,
+
actorHandle: { eq: handle },
+
},
+
};
+
}, [handle, dateRangeVariables]);
+
+
const data = useLazyLoadQuery<ProfileTopArtistsQuery>(
+
graphql`
+
query ProfileTopArtistsQuery($where: FmTealAlphaFeedPlayWhereInput) {
+
fmTealAlphaFeedPlaysAggregated(
+
groupBy: [{ field: artists }]
+
orderBy: { count: desc }
+
limit: 50
+
where: $where
+
) {
+
artists
+
count
+
}
+
}
+
`,
+
queryVariables,
+
{ fetchKey: `${handle}-${period || "all"}`, fetchPolicy: "store-or-network" }
+
);
+
+
const processedArtists = useMemo(() => {
+
const artistCounts: { [key: string]: number } = {};
+
+
(data.fmTealAlphaFeedPlaysAggregated || []).forEach((row) => {
+
if (!row.artists) return;
+
+
let names: string[] = [];
+
+
try {
+
const parsed = typeof row.artists === 'string' ? JSON.parse(row.artists) : row.artists;
+
+
if (Array.isArray(parsed)) {
+
names = parsed.map((a: { artistName: string }) => a.artistName.trim());
+
} else if (typeof parsed === 'string') {
+
names = parsed.split(',').map(s => s.trim());
+
}
+
} catch (e) {
+
if (typeof row.artists === 'string') {
+
names = row.artists.split(',').map(s => s.trim());
+
}
+
}
+
+
names.forEach(name => {
+
if (name) {
+
artistCounts[name] = (artistCounts[name] || 0) + row.count;
+
}
+
});
+
});
+
+
return Object.entries(artistCounts)
+
.map(([name, count]) => ({ artists: name, count }))
+
.sort((a, b) => b.count - a.count)
+
.slice(0, 10);
+
}, [data.fmTealAlphaFeedPlaysAggregated]);
+
+
const maxCount = processedArtists.length > 0 ? processedArtists[0].count : 0;
+
+
return (
+
<div className="space-y-1">
+
{processedArtists.map((artist, index) => (
+
<ArtistItem
+
key={`${artist.artists}-${index}`}
+
artists={artist.artists || "Unknown Artist"}
+
count={artist.count}
+
rank={index + 1}
+
maxCount={maxCount}
+
/>
+
))}
+
</div>
+
);
+
}
+59
src/ProfileTopTracks.tsx
···
+
import { graphql, useLazyLoadQuery } from "react-relay";
+
import { useParams } from "react-router-dom";
+
import type { ProfileTopTracksQuery } from "./__generated__/ProfileTopTracksQuery.graphql";
+
import TopTrackItem from "./TopTrackItem";
+
import { useDateRangeFilter } from "./useDateRangeFilter";
+
import { useMemo } from "react";
+
+
export default function ProfileTopTracks() {
+
const { handle, period } = useParams<{ handle: string; period?: string }>();
+
const dateRangeVariables = useDateRangeFilter(period);
+
+
const queryVariables = useMemo(() => {
+
return {
+
where: {
+
...dateRangeVariables.where,
+
actorHandle: { eq: handle },
+
},
+
};
+
}, [handle, dateRangeVariables]);
+
+
const data = useLazyLoadQuery<ProfileTopTracksQuery>(
+
graphql`
+
query ProfileTopTracksQuery($where: FmTealAlphaFeedPlayWhereInput) {
+
fmTealAlphaFeedPlaysAggregated(
+
groupBy: [{ field: trackName }, { field: releaseMbId }, { field: artists }]
+
orderBy: { count: desc }
+
limit: 10
+
where: $where
+
) {
+
trackName
+
releaseMbId
+
artists
+
count
+
}
+
}
+
`,
+
queryVariables,
+
{ fetchKey: `${handle}-${period || "all"}`, fetchPolicy: "store-or-network" }
+
);
+
+
const tracks = data.fmTealAlphaFeedPlaysAggregated || [];
+
const maxCount = tracks.length > 0 ? tracks[0].count : 0;
+
+
return (
+
<div className="space-y-1">
+
{tracks.map((track, index) => (
+
<TopTrackItem
+
key={`${track.trackName}-${index}`}
+
trackName={track.trackName || "Unknown Track"}
+
releaseMbId={track.releaseMbId}
+
artists={track.artists || "Unknown Artist"}
+
count={track.count}
+
rank={index + 1}
+
maxCount={maxCount}
+
/>
+
))}
+
</div>
+
);
+
}
+48
src/TopArtists.tsx
···
+
import { graphql, useLazyLoadQuery } from "react-relay";
+
import { useParams } from "react-router-dom";
+
import type { TopArtistsQuery } from "./__generated__/TopArtistsQuery.graphql";
+
import Layout from "./Layout";
+
import { useDateRangeFilter } from "./useDateRangeFilter";
+
import ArtistItem from "./ArtistItem";
+
+
export default function TopArtists() {
+
const { period } = useParams<{ period?: string }>();
+
const queryVariables = useDateRangeFilter(period);
+
+
const data = useLazyLoadQuery<TopArtistsQuery>(
+
graphql`
+
query TopArtistsQuery($where: FmTealAlphaFeedPlayWhereInput) {
+
fmTealAlphaFeedPlaysAggregated(
+
groupBy: [{ field: artists }]
+
orderBy: { count: desc }
+
limit: 50
+
where: $where
+
) {
+
artists
+
count
+
}
+
}
+
`,
+
queryVariables,
+
{ fetchKey: period || "all", fetchPolicy: "store-or-network" }
+
);
+
+
const artists = data.fmTealAlphaFeedPlaysAggregated || [];
+
const maxCount = artists.length > 0 ? artists[0].count : 0;
+
+
return (
+
<Layout>
+
<div className="space-y-1">
+
{artists.map((artist, index) => (
+
<ArtistItem
+
key={`${artist.artists}-${index}`}
+
artists={artist.artists || "Unknown Artist"}
+
count={artist.count}
+
rank={index + 1}
+
maxCount={maxCount}
+
/>
+
))}
+
</div>
+
</Layout>
+
);
+
}
+5 -5
src/__generated__/OverallQuery.graphql.ts
···
/**
-
* @generated SignedSource<<f25cea6543010242c587e91938f4b272>>
+
* @generated SignedSource<<be6b74e81f17f155aaa1449350560e4d>>
* @lightSyntaxTransform
* @nogrep
*/
···
{
"kind": "Literal",
"name": "limit",
-
"value": 10
+
"value": 20
},
{
"kind": "Literal",
···
"selections": (v1/*: any*/)
},
"params": {
-
"cacheID": "7b65bb3dad174d4a837c4bf5e67e5a89",
+
"cacheID": "8f8e0d5f64fdc442abebba83c5108588",
"id": null,
"metadata": {},
"name": "OverallQuery",
"operationKind": "query",
-
"text": "query OverallQuery(\n $where: FmTealAlphaFeedPlayWhereInput\n) {\n fmTealAlphaFeedPlaysAggregated(groupBy: [{field: releaseMbId}, {field: releaseName}, {field: artists}], orderBy: {count: desc}, limit: 10, where: $where) {\n releaseMbId\n releaseName\n artists\n count\n }\n}\n"
+
"text": "query OverallQuery(\n $where: FmTealAlphaFeedPlayWhereInput\n) {\n fmTealAlphaFeedPlaysAggregated(groupBy: [{field: releaseMbId}, {field: releaseName}, {field: artists}], orderBy: {count: desc}, limit: 20, where: $where) {\n releaseMbId\n releaseName\n artists\n count\n }\n}\n"
}
};
})();
-
(node as any).hash = "13785b9419a04f5c593536bcdde1ff27";
+
(node as any).hash = "0fe8889cb7ff66cacf0605bf0cad4cb2";
export default node;
+5 -36
src/__generated__/ProfilePaginationQuery.graphql.ts
···
/**
-
* @generated SignedSource<<ebf74750639e18cbf2425bae6cf23f69>>
+
* @generated SignedSource<<daf74bb66a4e6d9119e3b571e872c199>>
* @lightSyntaxTransform
* @nogrep
*/
···
"name": "displayName",
"storageKey": null
},
-
(v3/*: any*/),
-
{
-
"alias": null,
-
"args": null,
-
"kind": "ScalarField",
-
"name": "description",
-
"storageKey": null
-
},
-
{
-
"alias": null,
-
"args": null,
-
"concreteType": "Blob",
-
"kind": "LinkedField",
-
"name": "avatar",
-
"plural": false,
-
"selections": [
-
{
-
"alias": null,
-
"args": [
-
{
-
"kind": "Literal",
-
"name": "preset",
-
"value": "avatar"
-
}
-
],
-
"kind": "ScalarField",
-
"name": "url",
-
"storageKey": "url(preset:\"avatar\")"
-
}
-
],
-
"storageKey": null
-
}
+
(v3/*: any*/)
],
"storageKey": null
},
···
]
},
"params": {
-
"cacheID": "776562076929c2efa168256be3868659",
+
"cacheID": "a74a6087f9fec6f56ec166df70ad6365",
"id": null,
"metadata": {},
"name": "ProfilePaginationQuery",
"operationKind": "query",
-
"text": "query ProfilePaginationQuery(\n $count: Int = 20\n $cursor: String\n $where: FmTealAlphaFeedPlayWhereInput!\n) {\n ...Profile_plays_mjR8k\n}\n\nfragment Profile_plays_mjR8k on Query {\n fmTealAlphaFeedPlays(first: $count, after: $cursor, sortBy: [{field: playedTime, direction: desc}], where: $where) {\n totalCount\n edges {\n node {\n ...TrackItem_play\n actorHandle\n appBskyActorProfile {\n displayName\n description\n avatar {\n url(preset: \"avatar\")\n }\n id\n }\n id\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment TrackItem_play on FmTealAlphaFeedPlay {\n trackName\n playedTime\n artists {\n artistName\n artistMbId\n }\n releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\n id\n }\n}\n"
+
"text": "query ProfilePaginationQuery(\n $count: Int = 20\n $cursor: String\n $where: FmTealAlphaFeedPlayWhereInput!\n) {\n ...Profile_plays_mjR8k\n}\n\nfragment Profile_plays_mjR8k on Query {\n fmTealAlphaFeedPlays(first: $count, after: $cursor, sortBy: [{field: playedTime, direction: desc}], where: $where) {\n totalCount\n edges {\n node {\n ...TrackItem_play\n id\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment TrackItem_play on FmTealAlphaFeedPlay {\n trackName\n playedTime\n artists {\n artistName\n artistMbId\n }\n releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\n id\n }\n}\n"
}
};
})();
-
(node as any).hash = "fb9d67e8cd94c4191b9956225ff78bdf";
+
(node as any).hash = "42b3df3f8d988503f2b08000f01b4c83";
export default node;
+29 -116
src/__generated__/ProfileQuery.graphql.ts
···
/**
-
* @generated SignedSource<<0e53ec9fb8aaac689644785adae05957>>
+
* @generated SignedSource<<29d2f6473f1660ade97d5a93c8cab01a>>
* @lightSyntaxTransform
* @nogrep
*/
···
lte?: number | null | undefined;
};
export type ProfileQuery$variables = {
-
chartWhere: FmTealAlphaFeedPlayWhereInput;
where: FmTealAlphaFeedPlayWhereInput;
};
export type ProfileQuery$data = {
-
readonly " $fragmentSpreads": FragmentRefs<"Profile_plays" | "ScrobbleChart_data">;
+
readonly " $fragmentSpreads": FragmentRefs<"Profile_plays">;
};
export type ProfileQuery = {
response: ProfileQuery$data;
···
};
const node: ConcreteRequest = (function(){
-
var v0 = {
-
"defaultValue": null,
-
"kind": "LocalArgument",
-
"name": "chartWhere"
-
},
+
var v0 = [
+
{
+
"defaultValue": null,
+
"kind": "LocalArgument",
+
"name": "where"
+
}
+
],
v1 = {
-
"defaultValue": null,
-
"kind": "LocalArgument",
-
"name": "where"
-
},
-
v2 = {
"kind": "Variable",
"name": "where",
"variableName": "where"
},
-
v3 = [
+
v2 = [
{
"kind": "Literal",
"name": "first",
···
}
]
},
-
(v2/*: any*/)
+
(v1/*: any*/)
],
-
v4 = {
-
"alias": null,
-
"args": null,
-
"kind": "ScalarField",
-
"name": "playedTime",
-
"storageKey": null
-
},
-
v5 = {
+
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
···
};
return {
"fragment": {
-
"argumentDefinitions": [
-
(v0/*: any*/),
-
(v1/*: any*/)
-
],
+
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "ProfileQuery",
"selections": [
{
"args": [
-
(v2/*: any*/)
+
(v1/*: any*/)
],
"kind": "FragmentSpread",
"name": "Profile_plays"
-
},
-
{
-
"args": null,
-
"kind": "FragmentSpread",
-
"name": "ScrobbleChart_data"
}
],
"type": "Query",
···
},
"kind": "Request",
"operation": {
-
"argumentDefinitions": [
-
(v1/*: any*/),
-
(v0/*: any*/)
-
],
+
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "ProfileQuery",
"selections": [
{
"alias": null,
-
"args": (v3/*: any*/),
+
"args": (v2/*: any*/),
"concreteType": "FmTealAlphaFeedPlayConnection",
"kind": "LinkedField",
"name": "fmTealAlphaFeedPlays",
···
"name": "trackName",
"storageKey": null
},
-
(v4/*: any*/),
+
{
+
"alias": null,
+
"args": null,
+
"kind": "ScalarField",
+
"name": "playedTime",
+
"storageKey": null
+
},
{
"alias": null,
"args": null,
···
"name": "displayName",
"storageKey": null
},
-
(v5/*: any*/),
-
{
-
"alias": null,
-
"args": null,
-
"kind": "ScalarField",
-
"name": "description",
-
"storageKey": null
-
},
-
{
-
"alias": null,
-
"args": null,
-
"concreteType": "Blob",
-
"kind": "LinkedField",
-
"name": "avatar",
-
"plural": false,
-
"selections": [
-
{
-
"alias": null,
-
"args": [
-
{
-
"kind": "Literal",
-
"name": "preset",
-
"value": "avatar"
-
}
-
],
-
"kind": "ScalarField",
-
"name": "url",
-
"storageKey": "url(preset:\"avatar\")"
-
}
-
],
-
"storageKey": null
-
}
+
(v3/*: any*/)
],
"storageKey": null
},
-
(v5/*: any*/),
+
(v3/*: any*/),
{
"alias": null,
"args": null,
···
},
{
"alias": null,
-
"args": (v3/*: any*/),
+
"args": (v2/*: any*/),
"filters": [
"where",
"sortBy"
···
"key": "Profile_fmTealAlphaFeedPlays",
"kind": "LinkedHandle",
"name": "fmTealAlphaFeedPlays"
-
},
-
{
-
"alias": "chartData",
-
"args": [
-
{
-
"kind": "Literal",
-
"name": "groupBy",
-
"value": [
-
{
-
"field": "playedTime",
-
"interval": "day"
-
}
-
]
-
},
-
{
-
"kind": "Literal",
-
"name": "limit",
-
"value": 90
-
},
-
{
-
"kind": "Variable",
-
"name": "where",
-
"variableName": "chartWhere"
-
}
-
],
-
"concreteType": "FmTealAlphaFeedPlayAggregated",
-
"kind": "LinkedField",
-
"name": "fmTealAlphaFeedPlaysAggregated",
-
"plural": true,
-
"selections": [
-
(v4/*: any*/),
-
{
-
"alias": null,
-
"args": null,
-
"kind": "ScalarField",
-
"name": "count",
-
"storageKey": null
-
}
-
],
-
"storageKey": null
}
]
},
"params": {
-
"cacheID": "6555ddce48917a1e1a522f5b4800192b",
+
"cacheID": "d7aed8545b8651ae55d39d275c14fc74",
"id": null,
"metadata": {},
"name": "ProfileQuery",
"operationKind": "query",
-
"text": "query ProfileQuery(\n $where: FmTealAlphaFeedPlayWhereInput!\n $chartWhere: FmTealAlphaFeedPlayWhereInput!\n) {\n ...Profile_plays_3FC4Qo\n ...ScrobbleChart_data\n}\n\nfragment Profile_plays_3FC4Qo on Query {\n fmTealAlphaFeedPlays(first: 20, sortBy: [{field: playedTime, direction: desc}], where: $where) {\n totalCount\n edges {\n node {\n ...TrackItem_play\n actorHandle\n appBskyActorProfile {\n displayName\n description\n avatar {\n url(preset: \"avatar\")\n }\n id\n }\n id\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment ScrobbleChart_data on Query {\n chartData: fmTealAlphaFeedPlaysAggregated(groupBy: [{field: playedTime, interval: day}], where: $chartWhere, limit: 90) {\n playedTime\n count\n }\n}\n\nfragment TrackItem_play on FmTealAlphaFeedPlay {\n trackName\n playedTime\n artists {\n artistName\n artistMbId\n }\n releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\n id\n }\n}\n"
+
"text": "query ProfileQuery(\n $where: FmTealAlphaFeedPlayWhereInput!\n) {\n ...Profile_plays_3FC4Qo\n}\n\nfragment Profile_plays_3FC4Qo on Query {\n fmTealAlphaFeedPlays(first: 20, sortBy: [{field: playedTime, direction: desc}], where: $where) {\n totalCount\n edges {\n node {\n ...TrackItem_play\n id\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment TrackItem_play on FmTealAlphaFeedPlay {\n trackName\n playedTime\n artists {\n artistName\n artistMbId\n }\n releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\n id\n }\n}\n"
}
};
})();
-
(node as any).hash = "e000bb0fb9935e8e853d847c4362ffe6";
+
(node as any).hash = "4a0ecbad0ab4453246cdcdd753b1f84f";
export default node;
+5 -5
src/__generated__/ProfileTopAlbumsQuery.graphql.ts
···
/**
-
* @generated SignedSource<<53b006c990112d126e6ec82055bfb134>>
+
* @generated SignedSource<<f93041e209eda64391ae31c91b5b7c79>>
* @lightSyntaxTransform
* @nogrep
*/
···
{
"kind": "Literal",
"name": "limit",
-
"value": 100
+
"value": 50
},
{
"kind": "Literal",
···
"selections": (v1/*: any*/)
},
"params": {
-
"cacheID": "801b3ff75b3add55ea84e46588ff24d9",
+
"cacheID": "e3dea5c70916481cfc7237ea694fa720",
"id": null,
"metadata": {},
"name": "ProfileTopAlbumsQuery",
"operationKind": "query",
-
"text": "query ProfileTopAlbumsQuery(\n $where: FmTealAlphaFeedPlayWhereInput\n) {\n fmTealAlphaFeedPlaysAggregated(groupBy: [{field: releaseMbId}, {field: releaseName}, {field: artists}], orderBy: {count: desc}, limit: 100, where: $where) {\n releaseMbId\n releaseName\n artists\n count\n }\n}\n"
+
"text": "query ProfileTopAlbumsQuery(\n $where: FmTealAlphaFeedPlayWhereInput\n) {\n fmTealAlphaFeedPlaysAggregated(groupBy: [{field: releaseMbId}, {field: releaseName}, {field: artists}], orderBy: {count: desc}, limit: 50, where: $where) {\n releaseMbId\n releaseName\n artists\n count\n }\n}\n"
}
};
})();
-
(node as any).hash = "50bb6e6e1ebed4fab916c1aff7c4c257";
+
(node as any).hash = "61866b819fab81f5acac1c96479417da";
export default node;
+5 -5
src/__generated__/ProfileTopTracksQuery.graphql.ts
···
/**
-
* @generated SignedSource<<f0e880648a7f39f38d57e6c5570c06a2>>
+
* @generated SignedSource<<61cded348d713dac6a8c3871906a3d5c>>
* @lightSyntaxTransform
* @nogrep
*/
···
{
"kind": "Literal",
"name": "limit",
-
"value": 50
+
"value": 10
},
{
"kind": "Literal",
···
"selections": (v1/*: any*/)
},
"params": {
-
"cacheID": "68fd48ca986304ad7a9262e7e832913e",
+
"cacheID": "570221126c225955de88bb1dd21c9d9e",
"id": null,
"metadata": {},
"name": "ProfileTopTracksQuery",
"operationKind": "query",
-
"text": "query ProfileTopTracksQuery(\n $where: FmTealAlphaFeedPlayWhereInput\n) {\n fmTealAlphaFeedPlaysAggregated(groupBy: [{field: trackName}, {field: releaseMbId}, {field: artists}], orderBy: {count: desc}, limit: 50, where: $where) {\n trackName\n releaseMbId\n artists\n count\n }\n}\n"
+
"text": "query ProfileTopTracksQuery(\n $where: FmTealAlphaFeedPlayWhereInput\n) {\n fmTealAlphaFeedPlaysAggregated(groupBy: [{field: trackName}, {field: releaseMbId}, {field: artists}], orderBy: {count: desc}, limit: 10, where: $where) {\n trackName\n releaseMbId\n artists\n count\n }\n}\n"
}
};
})();
-
(node as any).hash = "d24e89a501ccd99c44648b5eb7928181";
+
(node as any).hash = "459f108ce53bf13ea9548756bb6698d5";
export default node;
+2 -66
src/__generated__/Profile_plays.graphql.ts
···
/**
-
* @generated SignedSource<<9b9347661ace6bcbeb677e53e4b5feec>>
+
* @generated SignedSource<<99fde612f90ee1d1dad1825b7b3b5f56>>
* @lightSyntaxTransform
* @nogrep
*/
···
readonly fmTealAlphaFeedPlays: {
readonly edges: ReadonlyArray<{
readonly node: {
-
readonly actorHandle: string | null | undefined;
-
readonly appBskyActorProfile: {
-
readonly avatar: {
-
readonly url: string;
-
} | null | undefined;
-
readonly description: string | null | undefined;
-
readonly displayName: string | null | undefined;
-
} | null | undefined;
readonly " $fragmentSpreads": FragmentRefs<"TrackItem_play">;
};
}>;
···
"alias": null,
"args": null,
"kind": "ScalarField",
-
"name": "actorHandle",
-
"storageKey": null
-
},
-
{
-
"alias": null,
-
"args": null,
-
"concreteType": "AppBskyActorProfile",
-
"kind": "LinkedField",
-
"name": "appBskyActorProfile",
-
"plural": false,
-
"selections": [
-
{
-
"alias": null,
-
"args": null,
-
"kind": "ScalarField",
-
"name": "displayName",
-
"storageKey": null
-
},
-
{
-
"alias": null,
-
"args": null,
-
"kind": "ScalarField",
-
"name": "description",
-
"storageKey": null
-
},
-
{
-
"alias": null,
-
"args": null,
-
"concreteType": "Blob",
-
"kind": "LinkedField",
-
"name": "avatar",
-
"plural": false,
-
"selections": [
-
{
-
"alias": null,
-
"args": [
-
{
-
"kind": "Literal",
-
"name": "preset",
-
"value": "avatar"
-
}
-
],
-
"kind": "ScalarField",
-
"name": "url",
-
"storageKey": "url(preset:\"avatar\")"
-
}
-
],
-
"storageKey": null
-
}
-
],
-
"storageKey": null
-
},
-
{
-
"alias": null,
-
"args": null,
-
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
···
};
})();
-
(node as any).hash = "fb9d67e8cd94c4191b9956225ff78bdf";
+
(node as any).hash = "42b3df3f8d988503f2b08000f01b4c83";
export default node;
+22 -2
src/main.tsx
···
import { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
-
import { BrowserRouter, Route, Routes } from "react-router-dom";
+
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import "./index.css";
import App from "./App.tsx";
import Profile from "./Profile.tsx";
···
type SubscribeFunction,
} from "relay-runtime";
import { createClient } from "graphql-ws";
+
import Overall from "./Overall.tsx";
+
import ProfileTopArtists from "./ProfileTopArtists.tsx";
+
import ProfileTopAlbums from "./ProfileTopAlbums.tsx";
+
import ProfileTopTracks from "./ProfileTopTracks.tsx";
+
import TopArtists from "./TopArtists.tsx";
const HTTP_ENDPOINT =
"https://api.slices.network/graphql?slice=at://did:plc:n2sgrmrxjell7f5oa5ruwlyl/network.slices.slice/3m5d5dfs3oy26";
···
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={<App />} />
+
<Route path="/artists" element={<TopArtists />} />
+
<Route path="/artists/:period" element={<TopArtists />} />
<Route path="/tracks" element={<TopTracks />} />
<Route path="/tracks/:period" element={<TopTracks />} />
<Route path="/albums" element={<TopAlbums />} />
<Route path="/albums/:period" element={<TopAlbums />} />
-
<Route path="/profile/:handle" element={<Profile />} />
+
<Route
+
path="/profile/:handle"
+
element={<Navigate to="scrobbles" replace />}
+
/>
+
<Route path="/profile/:handle/scrobbles" element={<Profile />} />
+
<Route path="/profile/:handle/overall" element={<Overall />}>
+
<Route index element={<Navigate to="artists" replace />} />
+
<Route path="artists" element={<ProfileTopArtists />} />
+
<Route path="artists/:period" element={<ProfileTopArtists />} />
+
<Route path="albums" element={<ProfileTopAlbums />} />
+
<Route path="albums/:period" element={<ProfileTopAlbums />} />
+
<Route path="tracks" element={<ProfileTopTracks />} />
+
<Route path="tracks/:period" element={<ProfileTopTracks />} />
+
</Route>
</Routes>
</Suspense>
</RelayEnvironmentProvider>