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

add scrobble charts and more top categories

+144 -28
schema.graphql
···
pinnedPost
}
+
input AppBskyActorProfileGroupByFieldInput {
+
field: AppBskyActorProfileGroupByField!
+
interval: DateInterval
+
}
+
+
input AppBskyActorProfileSortFieldInput {
+
field: AppBskyActorProfileGroupByField!
+
direction: SortDirection
+
}
+
input AppBskyActorProfileWhereInput {
indexedAt: DateTimeFilter
uri: StringFilter
···
external
}
+
input AppBskyEmbedExternalGroupByFieldInput {
+
field: AppBskyEmbedExternalGroupByField!
+
interval: DateInterval
+
}
+
+
input AppBskyEmbedExternalSortFieldInput {
+
field: AppBskyEmbedExternalGroupByField!
+
direction: SortDirection
+
}
+
input AppBskyEmbedExternalWhereInput {
indexedAt: DateTimeFilter
uri: StringFilter
···
images
}
+
input AppBskyEmbedImagesGroupByFieldInput {
+
field: AppBskyEmbedImagesGroupByField!
+
interval: DateInterval
+
}
+
+
input AppBskyEmbedImagesSortFieldInput {
+
field: AppBskyEmbedImagesGroupByField!
+
direction: SortDirection
+
}
+
input AppBskyEmbedImagesWhereInput {
indexedAt: DateTimeFilter
uri: StringFilter
···
record
}
+
input AppBskyEmbedRecordGroupByFieldInput {
+
field: AppBskyEmbedRecordGroupByField!
+
interval: DateInterval
+
}
+
+
input AppBskyEmbedRecordSortFieldInput {
+
field: AppBskyEmbedRecordGroupByField!
+
direction: SortDirection
+
}
+
input AppBskyEmbedRecordWhereInput {
indexedAt: DateTimeFilter
uri: StringFilter
···
record
}
+
input AppBskyEmbedRecordWithMediaGroupByFieldInput {
+
field: AppBskyEmbedRecordWithMediaGroupByField!
+
interval: DateInterval
+
}
+
+
input AppBskyEmbedRecordWithMediaSortFieldInput {
+
field: AppBskyEmbedRecordWithMediaGroupByField!
+
direction: SortDirection
+
}
+
input AppBskyEmbedRecordWithMediaWhereInput {
indexedAt: DateTimeFilter
uri: StringFilter
···
alt: String
aspectRatio: JSON
captions: JSON
-
video: Blob!
+
video: Blob
appBskyActorProfile: AppBskyActorProfile
appBskyFeedPostgates(limit: Int): [AppBskyFeedPostgate!]!
appBskyFeedPostgatesCount: Int!
···
video
}
+
input AppBskyEmbedVideoGroupByFieldInput {
+
field: AppBskyEmbedVideoGroupByField!
+
interval: DateInterval
+
}
+
+
input AppBskyEmbedVideoSortFieldInput {
+
field: AppBskyEmbedVideoGroupByField!
+
direction: SortDirection
+
}
+
input AppBskyEmbedVideoWhereInput {
indexedAt: DateTimeFilter
uri: StringFilter
···
post
}
+
input AppBskyFeedPostgateGroupByFieldInput {
+
field: AppBskyFeedPostgateGroupByField!
+
interval: DateInterval
+
}
+
+
input AppBskyFeedPostgateSortFieldInput {
+
field: AppBskyFeedPostgateGroupByField!
+
direction: SortDirection
+
}
+
input AppBskyFeedPostgateWhereInput {
indexedAt: DateTimeFilter
uri: StringFilter
···
post
}
+
input AppBskyFeedThreadgateGroupByFieldInput {
+
field: AppBskyFeedThreadgateGroupByField!
+
interval: DateInterval
+
}
+
+
input AppBskyFeedThreadgateSortFieldInput {
+
field: AppBskyFeedThreadgateGroupByField!
+
direction: SortDirection
+
}
+
input AppBskyFeedThreadgateWhereInput {
indexedAt: DateTimeFilter
uri: StringFilter
···
index
}
+
input AppBskyRichtextFacetGroupByFieldInput {
+
field: AppBskyRichtextFacetGroupByField!
+
interval: DateInterval
+
}
+
+
input AppBskyRichtextFacetSortFieldInput {
+
field: AppBskyRichtextFacetGroupByField!
+
direction: SortDirection
+
}
+
input AppBskyRichtextFacetWhereInput {
indexedAt: DateTimeFilter
uri: StringFilter
···
uri
}
+
input ComAtprotoRepoStrongRefGroupByFieldInput {
+
field: ComAtprotoRepoStrongRefGroupByField!
+
interval: DateInterval
+
}
+
+
input ComAtprotoRepoStrongRefSortFieldInput {
+
field: ComAtprotoRepoStrongRefGroupByField!
+
direction: SortDirection
+
}
+
input ComAtprotoRepoStrongRefWhereInput {
indexedAt: DateTimeFilter
did: StringFilter
···
uri: StringFilter
}
+
enum DateInterval {
+
second
+
minute
+
hour
+
day
+
week
+
month
+
quarter
+
year
+
}
+
input DateTimeFilter {
eq: String
gt: String
···
trackName
}
+
input FmTealAlphaFeedPlayGroupByFieldInput {
+
field: FmTealAlphaFeedPlayGroupByField!
+
interval: DateInterval
+
}
+
+
input FmTealAlphaFeedPlaySortFieldInput {
+
field: FmTealAlphaFeedPlayGroupByField!
+
direction: SortDirection
+
}
+
input FmTealAlphaFeedPlayWhereInput {
indexedAt: DateTimeFilter
uri: StringFilter
···
type Query {
"""Query app.bsky.embed.record records"""
-
appBskyEmbedRecords(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyEmbedRecordWhereInput): AppBskyEmbedRecordConnection!
+
appBskyEmbedRecords(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyEmbedRecordSortFieldInput], where: AppBskyEmbedRecordWhereInput): AppBskyEmbedRecordConnection!
"""
Aggregated query for app.bsky.embed.record records with GROUP BY support
"""
-
appBskyEmbedRecordsAggregated(groupBy: [AppBskyEmbedRecordGroupByField!], where: AppBskyEmbedRecordWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedRecordAggregated!]!
+
appBskyEmbedRecordsAggregated(groupBy: [AppBskyEmbedRecordGroupByFieldInput!], where: AppBskyEmbedRecordWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedRecordAggregated!]!
"""Query app.bsky.embed.images records"""
-
appBskyEmbedImageses(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyEmbedImagesWhereInput): AppBskyEmbedImagesConnection!
+
appBskyEmbedImageses(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyEmbedImagesSortFieldInput], where: AppBskyEmbedImagesWhereInput): AppBskyEmbedImagesConnection!
"""
Aggregated query for app.bsky.embed.images records with GROUP BY support
"""
-
appBskyEmbedImagesesAggregated(groupBy: [AppBskyEmbedImagesGroupByField!], where: AppBskyEmbedImagesWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedImagesAggregated!]!
+
appBskyEmbedImagesesAggregated(groupBy: [AppBskyEmbedImagesGroupByFieldInput!], where: AppBskyEmbedImagesWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedImagesAggregated!]!
"""Query app.bsky.embed.video records"""
-
appBskyEmbedVideos(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyEmbedVideoWhereInput): AppBskyEmbedVideoConnection!
+
appBskyEmbedVideos(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyEmbedVideoSortFieldInput], where: AppBskyEmbedVideoWhereInput): AppBskyEmbedVideoConnection!
"""
Aggregated query for app.bsky.embed.video records with GROUP BY support
"""
-
appBskyEmbedVideosAggregated(groupBy: [AppBskyEmbedVideoGroupByField!], where: AppBskyEmbedVideoWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedVideoAggregated!]!
+
appBskyEmbedVideosAggregated(groupBy: [AppBskyEmbedVideoGroupByFieldInput!], where: AppBskyEmbedVideoWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedVideoAggregated!]!
"""Query app.bsky.embed.recordWithMedia records"""
-
appBskyEmbedRecordWithMedias(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyEmbedRecordWithMediaWhereInput): AppBskyEmbedRecordWithMediaConnection!
+
appBskyEmbedRecordWithMedias(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyEmbedRecordWithMediaSortFieldInput], where: AppBskyEmbedRecordWithMediaWhereInput): AppBskyEmbedRecordWithMediaConnection!
"""
Aggregated query for app.bsky.embed.recordWithMedia records with GROUP BY support
"""
-
appBskyEmbedRecordWithMediasAggregated(groupBy: [AppBskyEmbedRecordWithMediaGroupByField!], where: AppBskyEmbedRecordWithMediaWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedRecordWithMediaAggregated!]!
+
appBskyEmbedRecordWithMediasAggregated(groupBy: [AppBskyEmbedRecordWithMediaGroupByFieldInput!], where: AppBskyEmbedRecordWithMediaWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedRecordWithMediaAggregated!]!
"""Query app.bsky.embed.external records"""
-
appBskyEmbedExternals(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyEmbedExternalWhereInput): AppBskyEmbedExternalConnection!
+
appBskyEmbedExternals(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyEmbedExternalSortFieldInput], where: AppBskyEmbedExternalWhereInput): AppBskyEmbedExternalConnection!
"""
Aggregated query for app.bsky.embed.external records with GROUP BY support
"""
-
appBskyEmbedExternalsAggregated(groupBy: [AppBskyEmbedExternalGroupByField!], where: AppBskyEmbedExternalWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedExternalAggregated!]!
+
appBskyEmbedExternalsAggregated(groupBy: [AppBskyEmbedExternalGroupByFieldInput!], where: AppBskyEmbedExternalWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedExternalAggregated!]!
"""Query app.bsky.feed.postgate records"""
-
appBskyFeedPostgates(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyFeedPostgateWhereInput): AppBskyFeedPostgateConnection!
+
appBskyFeedPostgates(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyFeedPostgateSortFieldInput], where: AppBskyFeedPostgateWhereInput): AppBskyFeedPostgateConnection!
"""
Aggregated query for app.bsky.feed.postgate records with GROUP BY support
"""
-
appBskyFeedPostgatesAggregated(groupBy: [AppBskyFeedPostgateGroupByField!], where: AppBskyFeedPostgateWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyFeedPostgateAggregated!]!
+
appBskyFeedPostgatesAggregated(groupBy: [AppBskyFeedPostgateGroupByFieldInput!], where: AppBskyFeedPostgateWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyFeedPostgateAggregated!]!
"""Query app.bsky.feed.threadgate records"""
-
appBskyFeedThreadgates(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyFeedThreadgateWhereInput): AppBskyFeedThreadgateConnection!
+
appBskyFeedThreadgates(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyFeedThreadgateSortFieldInput], where: AppBskyFeedThreadgateWhereInput): AppBskyFeedThreadgateConnection!
"""
Aggregated query for app.bsky.feed.threadgate records with GROUP BY support
"""
-
appBskyFeedThreadgatesAggregated(groupBy: [AppBskyFeedThreadgateGroupByField!], where: AppBskyFeedThreadgateWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyFeedThreadgateAggregated!]!
+
appBskyFeedThreadgatesAggregated(groupBy: [AppBskyFeedThreadgateGroupByFieldInput!], where: AppBskyFeedThreadgateWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyFeedThreadgateAggregated!]!
"""Query app.bsky.richtext.facet records"""
-
appBskyRichtextFacets(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyRichtextFacetWhereInput): AppBskyRichtextFacetConnection!
+
appBskyRichtextFacets(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyRichtextFacetSortFieldInput], where: AppBskyRichtextFacetWhereInput): AppBskyRichtextFacetConnection!
"""
Aggregated query for app.bsky.richtext.facet records with GROUP BY support
"""
-
appBskyRichtextFacetsAggregated(groupBy: [AppBskyRichtextFacetGroupByField!], where: AppBskyRichtextFacetWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyRichtextFacetAggregated!]!
+
appBskyRichtextFacetsAggregated(groupBy: [AppBskyRichtextFacetGroupByFieldInput!], where: AppBskyRichtextFacetWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyRichtextFacetAggregated!]!
"""Query app.bsky.actor.profile records"""
-
appBskyActorProfiles(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyActorProfileWhereInput): AppBskyActorProfileConnection!
+
appBskyActorProfiles(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyActorProfileSortFieldInput], where: AppBskyActorProfileWhereInput): AppBskyActorProfileConnection!
"""
Aggregated query for app.bsky.actor.profile records with GROUP BY support
"""
-
appBskyActorProfilesAggregated(groupBy: [AppBskyActorProfileGroupByField!], where: AppBskyActorProfileWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyActorProfileAggregated!]!
+
appBskyActorProfilesAggregated(groupBy: [AppBskyActorProfileGroupByFieldInput!], where: AppBskyActorProfileWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyActorProfileAggregated!]!
"""Query com.atproto.repo.strongRef records"""
-
comAtprotoRepoStrongRefs(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: ComAtprotoRepoStrongRefWhereInput): ComAtprotoRepoStrongRefConnection!
+
comAtprotoRepoStrongRefs(first: Int, after: String, last: Int, before: String, sortBy: [ComAtprotoRepoStrongRefSortFieldInput], where: ComAtprotoRepoStrongRefWhereInput): ComAtprotoRepoStrongRefConnection!
"""
Aggregated query for com.atproto.repo.strongRef records with GROUP BY support
"""
-
comAtprotoRepoStrongRefsAggregated(groupBy: [ComAtprotoRepoStrongRefGroupByField!], where: ComAtprotoRepoStrongRefWhereInput, orderBy: AggregationOrderBy, limit: Int): [ComAtprotoRepoStrongRefAggregated!]!
+
comAtprotoRepoStrongRefsAggregated(groupBy: [ComAtprotoRepoStrongRefGroupByFieldInput!], where: ComAtprotoRepoStrongRefWhereInput, orderBy: AggregationOrderBy, limit: Int): [ComAtprotoRepoStrongRefAggregated!]!
"""Query fm.teal.alpha.feed.play records"""
-
fmTealAlphaFeedPlays(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: FmTealAlphaFeedPlayWhereInput): FmTealAlphaFeedPlayConnection!
+
fmTealAlphaFeedPlays(first: Int, after: String, last: Int, before: String, sortBy: [FmTealAlphaFeedPlaySortFieldInput], where: FmTealAlphaFeedPlayWhereInput): FmTealAlphaFeedPlayConnection!
"""
Aggregated query for fm.teal.alpha.feed.play records with GROUP BY support
"""
-
fmTealAlphaFeedPlaysAggregated(groupBy: [FmTealAlphaFeedPlayGroupByField!], where: FmTealAlphaFeedPlayWhereInput, orderBy: AggregationOrderBy, limit: Int): [FmTealAlphaFeedPlayAggregated!]!
+
fmTealAlphaFeedPlaysAggregated(groupBy: [FmTealAlphaFeedPlayGroupByFieldInput!], where: FmTealAlphaFeedPlayWhereInput, orderBy: AggregationOrderBy, limit: Int): [FmTealAlphaFeedPlayAggregated!]!
}
enum SortDirection {
asc
desc
-
}
-
-
input SortField {
-
field: String!
-
direction: SortDirection!
}
input StringFilter {
+22 -5
src/App.tsx
···
usePaginationFragment,
useSubscription,
} from "react-relay";
-
import { useEffect, useRef } from "react";
+
import { useEffect, useRef, useMemo } from "react";
import type { AppQuery } from "./__generated__/AppQuery.graphql";
import type { App_plays$key } from "./__generated__/App_plays.graphql";
import type { AppSubscription } from "./__generated__/AppSubscription.graphql";
import TrackItem from "./TrackItem";
import Layout from "./Layout";
+
import ScrobbleChart from "./ScrobbleChart";
import {
ConnectionHandler,
type GraphQLSubscriptionConfig,
} from "relay-runtime";
export default function App() {
+
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 {
+
chartWhere: {
+
playedTime: {
+
gte: ninetyDaysAgo.toISOString(),
+
},
+
},
+
};
+
}, []);
+
const queryData = useLazyLoadQuery<AppQuery>(
graphql`
-
query AppQuery {
+
query AppQuery($chartWhere: FmTealAlphaFeedPlayWhereInput!) {
...App_plays
+
...ScrobbleChart_data
}
`,
-
{}
+
queryVariables
);
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment<
···
fmTealAlphaFeedPlays(
first: $count
after: $cursor
-
sortBy: [{ field: "playedTime", direction: desc }]
+
sortBy: [{ field: playedTime, direction: desc }]
) @connection(key: "App_fmTealAlphaFeedPlays", filters: ["sortBy"]) {
totalCount
edges {
···
});
return (
-
<Layout>
+
<Layout headerChart={<ScrobbleChart queryRef={queryData} />}>
<div className="mb-8">
<p className="text-xs text-zinc-500 uppercase tracking-wider">
{data?.fmTealAlphaFeedPlays?.totalCount?.toLocaleString()} scrobbles
+9 -3
src/Layout.tsx
···
interface LayoutProps {
children: React.ReactNode;
+
headerChart?: React.ReactNode;
}
-
export default function Layout({ children }: LayoutProps) {
+
export default function Layout({ children, headerChart }: LayoutProps) {
const location = useLocation();
const isTracksPage = location.pathname.startsWith("/tracks");
const isAlbumsPage = location.pathname.startsWith("/albums");
···
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">
-
<div className="mb-4 border-b border-zinc-800 pb-4">
-
<div className="flex items-end justify-between">
+
<div className="mb-4 border-b border-zinc-800 pb-4 relative">
+
{headerChart && (
+
<div className="absolute inset-0 pointer-events-none opacity-40">
+
{headerChart}
+
</div>
+
)}
+
<div className="flex items-end justify-between relative">
<div>
<h1 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Listening History</h1>
<p className="text-xs text-zinc-600 mt-1">fm.teal.alpha.feed.play</p>
+47 -22
src/Profile.tsx
···
import { graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay";
import { useParams, Link } from "react-router-dom";
-
import { useEffect, useRef } from "react";
+
import { useEffect, useRef, useMemo } 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";
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!) {
+
query ProfileQuery($where: FmTealAlphaFeedPlayWhereInput!, $chartWhere: FmTealAlphaFeedPlayWhereInput!) {
...Profile_plays @arguments(where: $where)
+
...ScrobbleChart_data
}
`,
-
{
-
where: { actorHandle: { eq: handle } },
-
}
+
queryVariables
);
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment<
···
fmTealAlphaFeedPlays(
first: $count
after: $cursor
-
sortBy: [{ field: "playedTime", direction: desc }]
+
sortBy: [{ field: playedTime, direction: desc }]
where: $where
)
@connection(
···
const loadMoreRef = useRef<HTMLDivElement>(null);
-
const plays = data?.fmTealAlphaFeedPlays?.edges?.map((edge) => edge.node).filter((n) => n != null) || [];
+
const plays = useMemo(
+
() => data?.fmTealAlphaFeedPlays?.edges?.map((edge) => edge.node).filter((n) => n != null) || [],
+
[data?.fmTealAlphaFeedPlays?.edges]
+
);
const profile = plays?.[0]?.appBskyActorProfile;
useEffect(() => {
···
← Back
</Link>
-
<div className="mb-12 flex items-start gap-6 border-b border-zinc-800 pb-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 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>
+113
src/ScrobbleChart.tsx
···
+
import { graphql, useFragment } from "react-relay";
+
import { useMemo } from "react";
+
import type { ScrobbleChart_data$key } from "./__generated__/ScrobbleChart_data.graphql";
+
+
interface ScrobbleChartProps {
+
queryRef: ScrobbleChart_data$key;
+
}
+
+
export default function ScrobbleChart({ queryRef }: ScrobbleChartProps) {
+
const data = useFragment(
+
graphql`
+
fragment ScrobbleChart_data on Query {
+
chartData: fmTealAlphaFeedPlaysAggregated(
+
groupBy: [{ field: playedTime, interval: day }]
+
where: $chartWhere
+
limit: 90
+
) {
+
playedTime
+
count
+
}
+
}
+
`,
+
queryRef
+
);
+
+
const chartData = useMemo(() => {
+
if (!data?.chartData) return [];
+
+
// Convert aggregated data to chart format
+
const aggregated = data.chartData.map((item) => {
+
// playedTime comes back as '2025-08-03 00:00:00', extract just the date part
+
const date = item.playedTime ? item.playedTime.split(' ')[0] : "";
+
return {
+
date,
+
count: item.count,
+
};
+
}).sort((a, b) => a.date.localeCompare(b.date));
+
+
// Fill in missing days with zero counts
+
const now = new Date();
+
now.setHours(0, 0, 0, 0);
+
const filledData = [];
+
+
for (let i = 89; i >= 0; i--) {
+
const date = new Date(now);
+
date.setDate(date.getDate() - i);
+
const dateStr = date.toISOString().split("T")[0];
+
+
const existing = aggregated.find((d) => d.date === dateStr);
+
filledData.push({
+
date: dateStr,
+
count: existing ? existing.count : 0,
+
});
+
}
+
+
return filledData;
+
}, [data?.chartData]);
+
+
if (!chartData || chartData.length === 0) return null;
+
+
const width = 1000;
+
const height = 100;
+
const padding = { top: 0, right: 0, bottom: 0, left: 0 };
+
const chartWidth = width - padding.left - padding.right;
+
const chartHeight = height - padding.top - padding.bottom;
+
+
const maxCount = Math.max(...chartData.map((d) => d.count));
+
const minCount = Math.min(...chartData.map((d) => d.count));
+
const range = maxCount - minCount || 1;
+
+
// Generate points for the line
+
const points = chartData.map((d, i) => {
+
const x = padding.left + (i / (chartData.length - 1)) * chartWidth;
+
const y = padding.top + chartHeight - ((d.count - minCount) / range) * chartHeight;
+
return `${x},${y}`;
+
}).join(" ");
+
+
// Generate area path
+
const areaPoints = [
+
`${padding.left},${padding.top + chartHeight}`,
+
...chartData.map((d, i) => {
+
const x = padding.left + (i / (chartData.length - 1)) * chartWidth;
+
const y = padding.top + chartHeight - ((d.count - minCount) / range) * chartHeight;
+
return `${x},${y}`;
+
}),
+
`${padding.left + chartWidth},${padding.top + chartHeight}`,
+
].join(" ");
+
+
return (
+
<svg
+
viewBox={`0 0 ${width} ${height}`}
+
className="w-full h-full"
+
preserveAspectRatio="none"
+
>
+
{/* Area fill */}
+
<polygon
+
points={areaPoints}
+
fill="rgb(139 92 246 / 0.1)"
+
stroke="none"
+
/>
+
+
{/* Line */}
+
<polyline
+
points={points}
+
fill="none"
+
stroke="rgb(139 92 246)"
+
strokeWidth="1.5"
+
strokeLinecap="round"
+
strokeLinejoin="round"
+
/>
+
</svg>
+
);
+
}
+1 -1
src/TopAlbums.tsx
···
graphql`
query TopAlbumsQuery($where: FmTealAlphaFeedPlayWhereInput) {
fmTealAlphaFeedPlaysAggregated(
-
groupBy: [releaseMbId, releaseName, artists]
+
groupBy: [{ field: releaseMbId }, { field: releaseName }, { field: artists }]
orderBy: { count: desc }
limit: 100
where: $where
+1 -1
src/TopTracks.tsx
···
graphql`
query TopTracksQuery($where: FmTealAlphaFeedPlayWhereInput) {
fmTealAlphaFeedPlaysAggregated(
-
groupBy: [trackName, releaseMbId, artists]
+
groupBy: [{ field: trackName }, { field: releaseMbId }, { field: artists }]
orderBy: { count: desc }
limit: 50
where: $where
+4 -4
src/__generated__/AppPaginationQuery.graphql.ts
···
/**
-
* @generated SignedSource<<93c3b304b5d8458925d44479b7dfa204>>
+
* @generated SignedSource<<38f32c1e1448eb48251113a07e781789>>
* @lightSyntaxTransform
* @nogrep
*/
···
]
},
"params": {
-
"cacheID": "71c3d1d480a2c2bc60d3b35d2f07d4eb",
+
"cacheID": "cb24b99f8b849bdfc642275cfd4df3fe",
"id": null,
"metadata": {},
"name": "AppPaginationQuery",
"operationKind": "query",
-
"text": "query AppPaginationQuery(\n $count: Int = 20\n $cursor: String\n) {\n ...App_plays_1G22uz\n}\n\nfragment App_plays_1G22uz on Query {\n fmTealAlphaFeedPlays(first: $count, after: $cursor, sortBy: [{field: \"playedTime\", direction: desc}]) {\n totalCount\n edges {\n node {\n playedTime\n ...TrackItem_play\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 releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\n }\n}\n"
+
"text": "query AppPaginationQuery(\n $count: Int = 20\n $cursor: String\n) {\n ...App_plays_1G22uz\n}\n\nfragment App_plays_1G22uz on Query {\n fmTealAlphaFeedPlays(first: $count, after: $cursor, sortBy: [{field: playedTime, direction: desc}]) {\n totalCount\n edges {\n node {\n playedTime\n ...TrackItem_play\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 releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\n }\n}\n"
}
};
})();
-
(node as any).hash = "0e4acf96fedae07af90ce6e9e3bf18d6";
+
(node as any).hash = "1e73fa97ccff20071e5a3fba0f00b48c";
export default node;
+119 -18
src/__generated__/AppQuery.graphql.ts
···
/**
-
* @generated SignedSource<<260fc65cac40538ad1a1673377c9e51d>>
+
* @generated SignedSource<<bd4d57eff6a192efe2535389231fe37e>>
* @lightSyntaxTransform
* @nogrep
*/
···
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
-
export type AppQuery$variables = Record<PropertyKey, never>;
+
export type FmTealAlphaFeedPlayWhereInput = {
+
actorHandle?: StringFilter | null | undefined;
+
artistMbIds?: StringFilter | null | undefined;
+
artistNames?: StringFilter | null | undefined;
+
artists?: StringFilter | null | undefined;
+
cid?: StringFilter | null | undefined;
+
collection?: StringFilter | null | undefined;
+
did?: StringFilter | null | undefined;
+
duration?: IntFilter | null | undefined;
+
indexedAt?: DateTimeFilter | null | undefined;
+
isrc?: StringFilter | null | undefined;
+
musicServiceBaseDomain?: StringFilter | null | undefined;
+
originUrl?: StringFilter | null | undefined;
+
playedTime?: StringFilter | null | undefined;
+
recordingMbId?: StringFilter | null | undefined;
+
releaseMbId?: StringFilter | null | undefined;
+
releaseName?: StringFilter | null | undefined;
+
submissionClientAgent?: StringFilter | null | undefined;
+
trackMbId?: StringFilter | null | undefined;
+
trackName?: StringFilter | null | undefined;
+
uri?: StringFilter | null | undefined;
+
};
+
export type DateTimeFilter = {
+
eq?: string | null | undefined;
+
gt?: string | null | undefined;
+
gte?: string | null | undefined;
+
lt?: string | null | undefined;
+
lte?: string | null | undefined;
+
};
+
export type StringFilter = {
+
contains?: string | null | undefined;
+
eq?: string | null | undefined;
+
gt?: string | null | undefined;
+
gte?: string | null | undefined;
+
in?: ReadonlyArray<string | null | undefined> | null | undefined;
+
lt?: string | null | undefined;
+
lte?: string | null | undefined;
+
};
+
export type IntFilter = {
+
eq?: number | null | undefined;
+
gt?: number | null | undefined;
+
gte?: number | null | undefined;
+
in?: ReadonlyArray<number | null | undefined> | null | undefined;
+
lt?: number | null | undefined;
+
lte?: number | null | undefined;
+
};
+
export type AppQuery$variables = {
+
chartWhere: FmTealAlphaFeedPlayWhereInput;
+
};
export type AppQuery$data = {
-
readonly " $fragmentSpreads": FragmentRefs<"App_plays">;
+
readonly " $fragmentSpreads": FragmentRefs<"App_plays" | "ScrobbleChart_data">;
};
export type AppQuery = {
response: AppQuery$data;
···
const node: ConcreteRequest = (function(){
var v0 = [
+
{
+
"defaultValue": null,
+
"kind": "LocalArgument",
+
"name": "chartWhere"
+
}
+
],
+
v1 = [
{
"kind": "Literal",
"name": "first",
···
}
]
}
-
];
+
],
+
v2 = {
+
"alias": null,
+
"args": null,
+
"kind": "ScalarField",
+
"name": "playedTime",
+
"storageKey": null
+
};
return {
"fragment": {
-
"argumentDefinitions": [],
+
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "AppQuery",
···
"args": null,
"kind": "FragmentSpread",
"name": "App_plays"
+
},
+
{
+
"args": null,
+
"kind": "FragmentSpread",
+
"name": "ScrobbleChart_data"
}
],
"type": "Query",
···
},
"kind": "Request",
"operation": {
-
"argumentDefinitions": [],
+
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "AppQuery",
"selections": [
{
"alias": null,
-
"args": (v0/*: any*/),
+
"args": (v1/*: any*/),
"concreteType": "FmTealAlphaFeedPlayConnection",
"kind": "LinkedField",
"name": "fmTealAlphaFeedPlays",
···
"name": "node",
"plural": false,
"selections": [
-
{
-
"alias": null,
-
"args": null,
-
"kind": "ScalarField",
-
"name": "playedTime",
-
"storageKey": null
-
},
+
(v2/*: any*/),
{
"alias": null,
"args": null,
···
},
{
"alias": null,
-
"args": (v0/*: any*/),
+
"args": (v1/*: any*/),
"filters": [
"sortBy"
],
···
"key": "App_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": [
+
(v2/*: any*/),
+
{
+
"alias": null,
+
"args": null,
+
"kind": "ScalarField",
+
"name": "count",
+
"storageKey": null
+
}
+
],
+
"storageKey": null
}
]
},
"params": {
-
"cacheID": "f3173a0a17eece6a35b00c37e787c484",
+
"cacheID": "038b79e3af13c34df9bfca055c5f7829",
"id": null,
"metadata": {},
"name": "AppQuery",
"operationKind": "query",
-
"text": "query AppQuery {\n ...App_plays\n}\n\nfragment App_plays on Query {\n fmTealAlphaFeedPlays(first: 20, sortBy: [{field: \"playedTime\", direction: desc}]) {\n totalCount\n edges {\n node {\n playedTime\n ...TrackItem_play\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 releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\n }\n}\n"
+
"text": "query AppQuery(\n $chartWhere: FmTealAlphaFeedPlayWhereInput!\n) {\n ...App_plays\n ...ScrobbleChart_data\n}\n\nfragment App_plays on Query {\n fmTealAlphaFeedPlays(first: 20, sortBy: [{field: playedTime, direction: desc}]) {\n totalCount\n edges {\n node {\n playedTime\n ...TrackItem_play\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 releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\n }\n}\n"
}
};
})();
-
(node as any).hash = "4b1837f6cd874e31461fbead77c1b012";
+
(node as any).hash = "7266612861cb55b740623549f1a03f26";
export default node;
+2 -2
src/__generated__/App_plays.graphql.ts
···
/**
-
* @generated SignedSource<<a3ae5f31f618986fb12e6c57458c9853>>
+
* @generated SignedSource<<ba0bacb4e016f0edbea67013c8694b23>>
* @lightSyntaxTransform
* @nogrep
*/
···
};
})();
-
(node as any).hash = "0e4acf96fedae07af90ce6e9e3bf18d6";
+
(node as any).hash = "1e73fa97ccff20071e5a3fba0f00b48c";
export default node;
+4 -4
src/__generated__/ProfilePaginationQuery.graphql.ts
···
/**
-
* @generated SignedSource<<9824b6fa6724ec81721b89464e18ee4f>>
+
* @generated SignedSource<<05e7a8d8804cbbe062ff7dce37522623>>
* @lightSyntaxTransform
* @nogrep
*/
···
]
},
"params": {
-
"cacheID": "6d52a3e02fe71c3ad54ec2006fc2ac45",
+
"cacheID": "72ce84bf8cc8ac016e19ca462a2f7b70",
"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 }\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 releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\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 actorHandle\n appBskyActorProfile {\n displayName\n description\n avatar {\n url(preset: \"avatar\")\n }\n }\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 releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\n }\n}\n"
}
};
})();
-
(node as any).hash = "86bf47e8cb24c938b0b5d7ad6f5cb916";
+
(node as any).hash = "fb9d67e8cd94c4191b9956225ff78bdf";
export default node;
+83 -27
src/__generated__/ProfileQuery.graphql.ts
···
/**
-
* @generated SignedSource<<0920331f4eccd3551cbc3ca8646596f0>>
+
* @generated SignedSource<<8d62f515b7652094345304e43124aa72>>
* @lightSyntaxTransform
* @nogrep
*/
···
lte?: number | null | undefined;
};
export type ProfileQuery$variables = {
+
chartWhere: FmTealAlphaFeedPlayWhereInput;
where: FmTealAlphaFeedPlayWhereInput;
};
export type ProfileQuery$data = {
-
readonly " $fragmentSpreads": FragmentRefs<"Profile_plays">;
+
readonly " $fragmentSpreads": FragmentRefs<"Profile_plays" | "ScrobbleChart_data">;
};
export type ProfileQuery = {
response: ProfileQuery$data;
···
};
const node: ConcreteRequest = (function(){
-
var v0 = [
-
{
-
"defaultValue": null,
-
"kind": "LocalArgument",
-
"name": "where"
-
}
-
],
+
var v0 = {
+
"defaultValue": null,
+
"kind": "LocalArgument",
+
"name": "chartWhere"
+
},
v1 = {
+
"defaultValue": null,
+
"kind": "LocalArgument",
+
"name": "where"
+
},
+
v2 = {
"kind": "Variable",
"name": "where",
"variableName": "where"
},
-
v2 = [
+
v3 = [
{
"kind": "Literal",
"name": "first",
···
}
]
},
-
(v1/*: any*/)
-
];
+
(v2/*: any*/)
+
],
+
v4 = {
+
"alias": null,
+
"args": null,
+
"kind": "ScalarField",
+
"name": "playedTime",
+
"storageKey": null
+
};
return {
"fragment": {
-
"argumentDefinitions": (v0/*: any*/),
+
"argumentDefinitions": [
+
(v0/*: any*/),
+
(v1/*: any*/)
+
],
"kind": "Fragment",
"metadata": null,
"name": "ProfileQuery",
"selections": [
{
"args": [
-
(v1/*: any*/)
+
(v2/*: any*/)
],
"kind": "FragmentSpread",
"name": "Profile_plays"
+
},
+
{
+
"args": null,
+
"kind": "FragmentSpread",
+
"name": "ScrobbleChart_data"
}
],
"type": "Query",
···
},
"kind": "Request",
"operation": {
-
"argumentDefinitions": (v0/*: any*/),
+
"argumentDefinitions": [
+
(v1/*: any*/),
+
(v0/*: any*/)
+
],
"kind": "Operation",
"name": "ProfileQuery",
"selections": [
{
"alias": null,
-
"args": (v2/*: any*/),
+
"args": (v3/*: any*/),
"concreteType": "FmTealAlphaFeedPlayConnection",
"kind": "LinkedField",
"name": "fmTealAlphaFeedPlays",
···
"name": "trackName",
"storageKey": null
},
-
{
-
"alias": null,
-
"args": null,
-
"kind": "ScalarField",
-
"name": "playedTime",
-
"storageKey": null
-
},
+
(v4/*: any*/),
{
"alias": null,
"args": null,
···
},
{
"alias": null,
-
"args": (v2/*: any*/),
+
"args": (v3/*: 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": "0592d86db07d88ab11657ea4cd107231",
+
"cacheID": "083f03714f183a8e68ff0a6d15f0d757",
"id": null,
"metadata": {},
"name": "ProfileQuery",
"operationKind": "query",
-
"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 actorHandle\n appBskyActorProfile {\n displayName\n description\n avatar {\n url(preset: \"avatar\")\n }\n }\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 releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\n }\n}\n"
+
"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 }\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 releaseName\n releaseMbId\n actorHandle\n musicServiceBaseDomain\n appBskyActorProfile {\n displayName\n }\n}\n"
}
};
})();
-
(node as any).hash = "4a0ecbad0ab4453246cdcdd753b1f84f";
+
(node as any).hash = "e000bb0fb9935e8e853d847c4362ffe6";
export default node;
+2 -2
src/__generated__/Profile_plays.graphql.ts
···
/**
-
* @generated SignedSource<<0e69127350e3dc66273c2ea60929dc92>>
+
* @generated SignedSource<<9b9347661ace6bcbeb677e53e4b5feec>>
* @lightSyntaxTransform
* @nogrep
*/
···
};
})();
-
(node as any).hash = "86bf47e8cb24c938b0b5d7ad6f5cb916";
+
(node as any).hash = "fb9d67e8cd94c4191b9956225ff78bdf";
export default node;
+89
src/__generated__/ScrobbleChart_data.graphql.ts
···
+
/**
+
* @generated SignedSource<<7b446f8950ffde63fb0e7748bb596e66>>
+
* @lightSyntaxTransform
+
* @nogrep
+
*/
+
+
/* tslint:disable */
+
/* eslint-disable */
+
// @ts-nocheck
+
+
import { ReaderFragment } from 'relay-runtime';
+
import { FragmentRefs } from "relay-runtime";
+
export type ScrobbleChart_data$data = {
+
readonly chartData: ReadonlyArray<{
+
readonly count: number;
+
readonly playedTime: any | null | undefined;
+
}>;
+
readonly " $fragmentType": "ScrobbleChart_data";
+
};
+
export type ScrobbleChart_data$key = {
+
readonly " $data"?: ScrobbleChart_data$data;
+
readonly " $fragmentSpreads": FragmentRefs<"ScrobbleChart_data">;
+
};
+
+
const node: ReaderFragment = {
+
"argumentDefinitions": [
+
{
+
"kind": "RootArgument",
+
"name": "chartWhere"
+
}
+
],
+
"kind": "Fragment",
+
"metadata": null,
+
"name": "ScrobbleChart_data",
+
"selections": [
+
{
+
"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": [
+
{
+
"alias": null,
+
"args": null,
+
"kind": "ScalarField",
+
"name": "playedTime",
+
"storageKey": null
+
},
+
{
+
"alias": null,
+
"args": null,
+
"kind": "ScalarField",
+
"name": "count",
+
"storageKey": null
+
}
+
],
+
"storageKey": null
+
}
+
],
+
"type": "Query",
+
"abstractKey": null
+
};
+
+
(node as any).hash = "6d8ebfa533779947a0b3cd703929b5ba";
+
+
export default node;
+13 -7
src/__generated__/TopAlbumsQuery.graphql.ts
···
/**
-
* @generated SignedSource<<8cf8cec6835334168002a2939635c9d5>>
+
* @generated SignedSource<<ba0a977fd251b8099185f84ffca5fe7f>>
* @lightSyntaxTransform
* @nogrep
*/
···
"kind": "Literal",
"name": "groupBy",
"value": [
-
"releaseMbId",
-
"releaseName",
-
"artists"
+
{
+
"field": "releaseMbId"
+
},
+
{
+
"field": "releaseName"
+
},
+
{
+
"field": "artists"
+
}
]
},
{
···
"selections": (v1/*: any*/)
},
"params": {
-
"cacheID": "bb227295300710370e7e5c2492532c01",
+
"cacheID": "4bc742f9cab572a86f4956ae1325e650",
"id": null,
"metadata": {},
"name": "TopAlbumsQuery",
"operationKind": "query",
-
"text": "query TopAlbumsQuery(\n $where: FmTealAlphaFeedPlayWhereInput\n) {\n fmTealAlphaFeedPlaysAggregated(groupBy: [releaseMbId, releaseName, artists], orderBy: {count: desc}, limit: 100, where: $where) {\n releaseMbId\n releaseName\n artists\n count\n }\n}\n"
+
"text": "query TopAlbumsQuery(\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"
}
};
})();
-
(node as any).hash = "6e30827615eb8acfde3c0c80598b6627";
+
(node as any).hash = "c916cfe287c6837e7b40f0712b123f12";
export default node;
+13 -7
src/__generated__/TopTracksQuery.graphql.ts
···
/**
-
* @generated SignedSource<<2c2f4cf7a049eff39002109dffd04288>>
+
* @generated SignedSource<<d82ad2bc23a12ef33ba6bce1df9620f3>>
* @lightSyntaxTransform
* @nogrep
*/
···
"kind": "Literal",
"name": "groupBy",
"value": [
-
"trackName",
-
"releaseMbId",
-
"artists"
+
{
+
"field": "trackName"
+
},
+
{
+
"field": "releaseMbId"
+
},
+
{
+
"field": "artists"
+
}
]
},
{
···
"selections": (v1/*: any*/)
},
"params": {
-
"cacheID": "cbf23694acc55e8dfbe9296500193932",
+
"cacheID": "d889d685b64fb19d468954bb3fb7ff7c",
"id": null,
"metadata": {},
"name": "TopTracksQuery",
"operationKind": "query",
-
"text": "query TopTracksQuery(\n $where: FmTealAlphaFeedPlayWhereInput\n) {\n fmTealAlphaFeedPlaysAggregated(groupBy: [trackName, releaseMbId, artists], orderBy: {count: desc}, limit: 50, where: $where) {\n trackName\n releaseMbId\n artists\n count\n }\n}\n"
+
"text": "query TopTracksQuery(\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"
}
};
})();
-
(node as any).hash = "6b649e9c39df41d4ce995e69e6dc6f35";
+
(node as any).hash = "4b62eaeaf8a935abc28e77c8cd2907d1";
export default node;
+37
src/generateChartData.ts
···
+
export interface DataPoint {
+
date: string;
+
count: number;
+
}
+
+
export function generateChartData(
+
plays: readonly { readonly playedTime?: string | null; readonly [key: string]: any }[],
+
days = 90
+
): DataPoint[] {
+
const counts = new Map<string, number>();
+
const now = new Date();
+
+
// Initialize last N days with 0 counts
+
for (let i = days - 1; i >= 0; i--) {
+
const date = new Date(now);
+
date.setDate(date.getDate() - i);
+
date.setHours(0, 0, 0, 0);
+
const dateStr = date.toISOString().split("T")[0];
+
counts.set(dateStr, 0);
+
}
+
+
// Count plays per day
+
plays.forEach((play) => {
+
if (play?.playedTime) {
+
const date = new Date(play.playedTime);
+
date.setHours(0, 0, 0, 0);
+
const dateStr = date.toISOString().split("T")[0];
+
if (counts.has(dateStr)) {
+
counts.set(dateStr, (counts.get(dateStr) || 0) + 1);
+
}
+
}
+
});
+
+
return Array.from(counts.entries())
+
.map(([date, count]) => ({ date, count }))
+
.sort((a, b) => a.date.localeCompare(b.date));
+
}