+144
-28
schema.graphql
+144
-28
schema.graphql
···
76
76
pinnedPost
77
77
}
78
78
79
+
input AppBskyActorProfileGroupByFieldInput {
80
+
field: AppBskyActorProfileGroupByField!
81
+
interval: DateInterval
82
+
}
83
+
84
+
input AppBskyActorProfileSortFieldInput {
85
+
field: AppBskyActorProfileGroupByField!
86
+
direction: SortDirection
87
+
}
88
+
79
89
input AppBskyActorProfileWhereInput {
80
90
indexedAt: DateTimeFilter
81
91
uri: StringFilter
···
133
143
external
134
144
}
135
145
146
+
input AppBskyEmbedExternalGroupByFieldInput {
147
+
field: AppBskyEmbedExternalGroupByField!
148
+
interval: DateInterval
149
+
}
150
+
151
+
input AppBskyEmbedExternalSortFieldInput {
152
+
field: AppBskyEmbedExternalGroupByField!
153
+
direction: SortDirection
154
+
}
155
+
136
156
input AppBskyEmbedExternalWhereInput {
137
157
indexedAt: DateTimeFilter
138
158
uri: StringFilter
···
183
203
images
184
204
}
185
205
206
+
input AppBskyEmbedImagesGroupByFieldInput {
207
+
field: AppBskyEmbedImagesGroupByField!
208
+
interval: DateInterval
209
+
}
210
+
211
+
input AppBskyEmbedImagesSortFieldInput {
212
+
field: AppBskyEmbedImagesGroupByField!
213
+
direction: SortDirection
214
+
}
215
+
186
216
input AppBskyEmbedImagesWhereInput {
187
217
indexedAt: DateTimeFilter
188
218
uri: StringFilter
···
233
263
record
234
264
}
235
265
266
+
input AppBskyEmbedRecordGroupByFieldInput {
267
+
field: AppBskyEmbedRecordGroupByField!
268
+
interval: DateInterval
269
+
}
270
+
271
+
input AppBskyEmbedRecordSortFieldInput {
272
+
field: AppBskyEmbedRecordGroupByField!
273
+
direction: SortDirection
274
+
}
275
+
236
276
input AppBskyEmbedRecordWhereInput {
237
277
indexedAt: DateTimeFilter
238
278
uri: StringFilter
···
286
326
record
287
327
}
288
328
329
+
input AppBskyEmbedRecordWithMediaGroupByFieldInput {
330
+
field: AppBskyEmbedRecordWithMediaGroupByField!
331
+
interval: DateInterval
332
+
}
333
+
334
+
input AppBskyEmbedRecordWithMediaSortFieldInput {
335
+
field: AppBskyEmbedRecordWithMediaGroupByField!
336
+
direction: SortDirection
337
+
}
338
+
289
339
input AppBskyEmbedRecordWithMediaWhereInput {
290
340
indexedAt: DateTimeFilter
291
341
uri: StringFilter
···
306
356
alt: String
307
357
aspectRatio: JSON
308
358
captions: JSON
309
-
video: Blob!
359
+
video: Blob
310
360
appBskyActorProfile: AppBskyActorProfile
311
361
appBskyFeedPostgates(limit: Int): [AppBskyFeedPostgate!]!
312
362
appBskyFeedPostgatesCount: Int!
···
346
396
video
347
397
}
348
398
399
+
input AppBskyEmbedVideoGroupByFieldInput {
400
+
field: AppBskyEmbedVideoGroupByField!
401
+
interval: DateInterval
402
+
}
403
+
404
+
input AppBskyEmbedVideoSortFieldInput {
405
+
field: AppBskyEmbedVideoGroupByField!
406
+
direction: SortDirection
407
+
}
408
+
349
409
input AppBskyEmbedVideoWhereInput {
350
410
indexedAt: DateTimeFilter
351
411
uri: StringFilter
···
411
471
post
412
472
}
413
473
474
+
input AppBskyFeedPostgateGroupByFieldInput {
475
+
field: AppBskyFeedPostgateGroupByField!
476
+
interval: DateInterval
477
+
}
478
+
479
+
input AppBskyFeedPostgateSortFieldInput {
480
+
field: AppBskyFeedPostgateGroupByField!
481
+
direction: SortDirection
482
+
}
483
+
414
484
input AppBskyFeedPostgateWhereInput {
415
485
indexedAt: DateTimeFilter
416
486
uri: StringFilter
···
476
546
post
477
547
}
478
548
549
+
input AppBskyFeedThreadgateGroupByFieldInput {
550
+
field: AppBskyFeedThreadgateGroupByField!
551
+
interval: DateInterval
552
+
}
553
+
554
+
input AppBskyFeedThreadgateSortFieldInput {
555
+
field: AppBskyFeedThreadgateGroupByField!
556
+
direction: SortDirection
557
+
}
558
+
479
559
input AppBskyFeedThreadgateWhereInput {
480
560
indexedAt: DateTimeFilter
481
561
uri: StringFilter
···
532
612
index
533
613
}
534
614
615
+
input AppBskyRichtextFacetGroupByFieldInput {
616
+
field: AppBskyRichtextFacetGroupByField!
617
+
interval: DateInterval
618
+
}
619
+
620
+
input AppBskyRichtextFacetSortFieldInput {
621
+
field: AppBskyRichtextFacetGroupByField!
622
+
direction: SortDirection
623
+
}
624
+
535
625
input AppBskyRichtextFacetWhereInput {
536
626
indexedAt: DateTimeFilter
537
627
uri: StringFilter
···
598
688
uri
599
689
}
600
690
691
+
input ComAtprotoRepoStrongRefGroupByFieldInput {
692
+
field: ComAtprotoRepoStrongRefGroupByField!
693
+
interval: DateInterval
694
+
}
695
+
696
+
input ComAtprotoRepoStrongRefSortFieldInput {
697
+
field: ComAtprotoRepoStrongRefGroupByField!
698
+
direction: SortDirection
699
+
}
700
+
601
701
input ComAtprotoRepoStrongRefWhereInput {
602
702
indexedAt: DateTimeFilter
603
703
did: StringFilter
···
607
707
uri: StringFilter
608
708
}
609
709
710
+
enum DateInterval {
711
+
second
712
+
minute
713
+
hour
714
+
day
715
+
week
716
+
month
717
+
quarter
718
+
year
719
+
}
720
+
610
721
input DateTimeFilter {
611
722
eq: String
612
723
gt: String
···
694
805
trackName
695
806
}
696
807
808
+
input FmTealAlphaFeedPlayGroupByFieldInput {
809
+
field: FmTealAlphaFeedPlayGroupByField!
810
+
interval: DateInterval
811
+
}
812
+
813
+
input FmTealAlphaFeedPlaySortFieldInput {
814
+
field: FmTealAlphaFeedPlayGroupByField!
815
+
direction: SortDirection
816
+
}
817
+
697
818
input FmTealAlphaFeedPlayWhereInput {
698
819
indexedAt: DateTimeFilter
699
820
uri: StringFilter
···
742
863
743
864
type Query {
744
865
"""Query app.bsky.embed.record records"""
745
-
appBskyEmbedRecords(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyEmbedRecordWhereInput): AppBskyEmbedRecordConnection!
866
+
appBskyEmbedRecords(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyEmbedRecordSortFieldInput], where: AppBskyEmbedRecordWhereInput): AppBskyEmbedRecordConnection!
746
867
747
868
"""
748
869
Aggregated query for app.bsky.embed.record records with GROUP BY support
749
870
"""
750
-
appBskyEmbedRecordsAggregated(groupBy: [AppBskyEmbedRecordGroupByField!], where: AppBskyEmbedRecordWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedRecordAggregated!]!
871
+
appBskyEmbedRecordsAggregated(groupBy: [AppBskyEmbedRecordGroupByFieldInput!], where: AppBskyEmbedRecordWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedRecordAggregated!]!
751
872
752
873
"""Query app.bsky.embed.images records"""
753
-
appBskyEmbedImageses(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyEmbedImagesWhereInput): AppBskyEmbedImagesConnection!
874
+
appBskyEmbedImageses(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyEmbedImagesSortFieldInput], where: AppBskyEmbedImagesWhereInput): AppBskyEmbedImagesConnection!
754
875
755
876
"""
756
877
Aggregated query for app.bsky.embed.images records with GROUP BY support
757
878
"""
758
-
appBskyEmbedImagesesAggregated(groupBy: [AppBskyEmbedImagesGroupByField!], where: AppBskyEmbedImagesWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedImagesAggregated!]!
879
+
appBskyEmbedImagesesAggregated(groupBy: [AppBskyEmbedImagesGroupByFieldInput!], where: AppBskyEmbedImagesWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedImagesAggregated!]!
759
880
760
881
"""Query app.bsky.embed.video records"""
761
-
appBskyEmbedVideos(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyEmbedVideoWhereInput): AppBskyEmbedVideoConnection!
882
+
appBskyEmbedVideos(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyEmbedVideoSortFieldInput], where: AppBskyEmbedVideoWhereInput): AppBskyEmbedVideoConnection!
762
883
763
884
"""
764
885
Aggregated query for app.bsky.embed.video records with GROUP BY support
765
886
"""
766
-
appBskyEmbedVideosAggregated(groupBy: [AppBskyEmbedVideoGroupByField!], where: AppBskyEmbedVideoWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedVideoAggregated!]!
887
+
appBskyEmbedVideosAggregated(groupBy: [AppBskyEmbedVideoGroupByFieldInput!], where: AppBskyEmbedVideoWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedVideoAggregated!]!
767
888
768
889
"""Query app.bsky.embed.recordWithMedia records"""
769
-
appBskyEmbedRecordWithMedias(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyEmbedRecordWithMediaWhereInput): AppBskyEmbedRecordWithMediaConnection!
890
+
appBskyEmbedRecordWithMedias(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyEmbedRecordWithMediaSortFieldInput], where: AppBskyEmbedRecordWithMediaWhereInput): AppBskyEmbedRecordWithMediaConnection!
770
891
771
892
"""
772
893
Aggregated query for app.bsky.embed.recordWithMedia records with GROUP BY support
773
894
"""
774
-
appBskyEmbedRecordWithMediasAggregated(groupBy: [AppBskyEmbedRecordWithMediaGroupByField!], where: AppBskyEmbedRecordWithMediaWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedRecordWithMediaAggregated!]!
895
+
appBskyEmbedRecordWithMediasAggregated(groupBy: [AppBskyEmbedRecordWithMediaGroupByFieldInput!], where: AppBskyEmbedRecordWithMediaWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedRecordWithMediaAggregated!]!
775
896
776
897
"""Query app.bsky.embed.external records"""
777
-
appBskyEmbedExternals(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyEmbedExternalWhereInput): AppBskyEmbedExternalConnection!
898
+
appBskyEmbedExternals(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyEmbedExternalSortFieldInput], where: AppBskyEmbedExternalWhereInput): AppBskyEmbedExternalConnection!
778
899
779
900
"""
780
901
Aggregated query for app.bsky.embed.external records with GROUP BY support
781
902
"""
782
-
appBskyEmbedExternalsAggregated(groupBy: [AppBskyEmbedExternalGroupByField!], where: AppBskyEmbedExternalWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedExternalAggregated!]!
903
+
appBskyEmbedExternalsAggregated(groupBy: [AppBskyEmbedExternalGroupByFieldInput!], where: AppBskyEmbedExternalWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyEmbedExternalAggregated!]!
783
904
784
905
"""Query app.bsky.feed.postgate records"""
785
-
appBskyFeedPostgates(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyFeedPostgateWhereInput): AppBskyFeedPostgateConnection!
906
+
appBskyFeedPostgates(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyFeedPostgateSortFieldInput], where: AppBskyFeedPostgateWhereInput): AppBskyFeedPostgateConnection!
786
907
787
908
"""
788
909
Aggregated query for app.bsky.feed.postgate records with GROUP BY support
789
910
"""
790
-
appBskyFeedPostgatesAggregated(groupBy: [AppBskyFeedPostgateGroupByField!], where: AppBskyFeedPostgateWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyFeedPostgateAggregated!]!
911
+
appBskyFeedPostgatesAggregated(groupBy: [AppBskyFeedPostgateGroupByFieldInput!], where: AppBskyFeedPostgateWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyFeedPostgateAggregated!]!
791
912
792
913
"""Query app.bsky.feed.threadgate records"""
793
-
appBskyFeedThreadgates(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyFeedThreadgateWhereInput): AppBskyFeedThreadgateConnection!
914
+
appBskyFeedThreadgates(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyFeedThreadgateSortFieldInput], where: AppBskyFeedThreadgateWhereInput): AppBskyFeedThreadgateConnection!
794
915
795
916
"""
796
917
Aggregated query for app.bsky.feed.threadgate records with GROUP BY support
797
918
"""
798
-
appBskyFeedThreadgatesAggregated(groupBy: [AppBskyFeedThreadgateGroupByField!], where: AppBskyFeedThreadgateWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyFeedThreadgateAggregated!]!
919
+
appBskyFeedThreadgatesAggregated(groupBy: [AppBskyFeedThreadgateGroupByFieldInput!], where: AppBskyFeedThreadgateWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyFeedThreadgateAggregated!]!
799
920
800
921
"""Query app.bsky.richtext.facet records"""
801
-
appBskyRichtextFacets(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyRichtextFacetWhereInput): AppBskyRichtextFacetConnection!
922
+
appBskyRichtextFacets(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyRichtextFacetSortFieldInput], where: AppBskyRichtextFacetWhereInput): AppBskyRichtextFacetConnection!
802
923
803
924
"""
804
925
Aggregated query for app.bsky.richtext.facet records with GROUP BY support
805
926
"""
806
-
appBskyRichtextFacetsAggregated(groupBy: [AppBskyRichtextFacetGroupByField!], where: AppBskyRichtextFacetWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyRichtextFacetAggregated!]!
927
+
appBskyRichtextFacetsAggregated(groupBy: [AppBskyRichtextFacetGroupByFieldInput!], where: AppBskyRichtextFacetWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyRichtextFacetAggregated!]!
807
928
808
929
"""Query app.bsky.actor.profile records"""
809
-
appBskyActorProfiles(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: AppBskyActorProfileWhereInput): AppBskyActorProfileConnection!
930
+
appBskyActorProfiles(first: Int, after: String, last: Int, before: String, sortBy: [AppBskyActorProfileSortFieldInput], where: AppBskyActorProfileWhereInput): AppBskyActorProfileConnection!
810
931
811
932
"""
812
933
Aggregated query for app.bsky.actor.profile records with GROUP BY support
813
934
"""
814
-
appBskyActorProfilesAggregated(groupBy: [AppBskyActorProfileGroupByField!], where: AppBskyActorProfileWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyActorProfileAggregated!]!
935
+
appBskyActorProfilesAggregated(groupBy: [AppBskyActorProfileGroupByFieldInput!], where: AppBskyActorProfileWhereInput, orderBy: AggregationOrderBy, limit: Int): [AppBskyActorProfileAggregated!]!
815
936
816
937
"""Query com.atproto.repo.strongRef records"""
817
-
comAtprotoRepoStrongRefs(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: ComAtprotoRepoStrongRefWhereInput): ComAtprotoRepoStrongRefConnection!
938
+
comAtprotoRepoStrongRefs(first: Int, after: String, last: Int, before: String, sortBy: [ComAtprotoRepoStrongRefSortFieldInput], where: ComAtprotoRepoStrongRefWhereInput): ComAtprotoRepoStrongRefConnection!
818
939
819
940
"""
820
941
Aggregated query for com.atproto.repo.strongRef records with GROUP BY support
821
942
"""
822
-
comAtprotoRepoStrongRefsAggregated(groupBy: [ComAtprotoRepoStrongRefGroupByField!], where: ComAtprotoRepoStrongRefWhereInput, orderBy: AggregationOrderBy, limit: Int): [ComAtprotoRepoStrongRefAggregated!]!
943
+
comAtprotoRepoStrongRefsAggregated(groupBy: [ComAtprotoRepoStrongRefGroupByFieldInput!], where: ComAtprotoRepoStrongRefWhereInput, orderBy: AggregationOrderBy, limit: Int): [ComAtprotoRepoStrongRefAggregated!]!
823
944
824
945
"""Query fm.teal.alpha.feed.play records"""
825
-
fmTealAlphaFeedPlays(first: Int, after: String, last: Int, before: String, sortBy: [SortField], where: FmTealAlphaFeedPlayWhereInput): FmTealAlphaFeedPlayConnection!
946
+
fmTealAlphaFeedPlays(first: Int, after: String, last: Int, before: String, sortBy: [FmTealAlphaFeedPlaySortFieldInput], where: FmTealAlphaFeedPlayWhereInput): FmTealAlphaFeedPlayConnection!
826
947
827
948
"""
828
949
Aggregated query for fm.teal.alpha.feed.play records with GROUP BY support
829
950
"""
830
-
fmTealAlphaFeedPlaysAggregated(groupBy: [FmTealAlphaFeedPlayGroupByField!], where: FmTealAlphaFeedPlayWhereInput, orderBy: AggregationOrderBy, limit: Int): [FmTealAlphaFeedPlayAggregated!]!
951
+
fmTealAlphaFeedPlaysAggregated(groupBy: [FmTealAlphaFeedPlayGroupByFieldInput!], where: FmTealAlphaFeedPlayWhereInput, orderBy: AggregationOrderBy, limit: Int): [FmTealAlphaFeedPlayAggregated!]!
831
952
}
832
953
833
954
enum SortDirection {
834
955
asc
835
956
desc
836
-
}
837
-
838
-
input SortField {
839
-
field: String!
840
-
direction: SortDirection!
841
957
}
842
958
843
959
input StringFilter {
+22
-5
src/App.tsx
+22
-5
src/App.tsx
···
4
4
usePaginationFragment,
5
5
useSubscription,
6
6
} from "react-relay";
7
-
import { useEffect, useRef } from "react";
7
+
import { useEffect, useRef, useMemo } from "react";
8
8
import type { AppQuery } from "./__generated__/AppQuery.graphql";
9
9
import type { App_plays$key } from "./__generated__/App_plays.graphql";
10
10
import type { AppSubscription } from "./__generated__/AppSubscription.graphql";
11
11
import TrackItem from "./TrackItem";
12
12
import Layout from "./Layout";
13
+
import ScrobbleChart from "./ScrobbleChart";
13
14
import {
14
15
ConnectionHandler,
15
16
type GraphQLSubscriptionConfig,
16
17
} from "relay-runtime";
17
18
18
19
export default function App() {
20
+
const queryVariables = useMemo(() => {
21
+
// Round to start of day to keep timestamp stable
22
+
const now = new Date();
23
+
now.setHours(0, 0, 0, 0);
24
+
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
25
+
26
+
return {
27
+
chartWhere: {
28
+
playedTime: {
29
+
gte: ninetyDaysAgo.toISOString(),
30
+
},
31
+
},
32
+
};
33
+
}, []);
34
+
19
35
const queryData = useLazyLoadQuery<AppQuery>(
20
36
graphql`
21
-
query AppQuery {
37
+
query AppQuery($chartWhere: FmTealAlphaFeedPlayWhereInput!) {
22
38
...App_plays
39
+
...ScrobbleChart_data
23
40
}
24
41
`,
25
-
{}
42
+
queryVariables
26
43
);
27
44
28
45
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment<
···
39
56
fmTealAlphaFeedPlays(
40
57
first: $count
41
58
after: $cursor
42
-
sortBy: [{ field: "playedTime", direction: desc }]
59
+
sortBy: [{ field: playedTime, direction: desc }]
43
60
) @connection(key: "App_fmTealAlphaFeedPlays", filters: ["sortBy"]) {
44
61
totalCount
45
62
edges {
···
154
171
});
155
172
156
173
return (
157
-
<Layout>
174
+
<Layout headerChart={<ScrobbleChart queryRef={queryData} />}>
158
175
<div className="mb-8">
159
176
<p className="text-xs text-zinc-500 uppercase tracking-wider">
160
177
{data?.fmTealAlphaFeedPlays?.totalCount?.toLocaleString()} scrobbles
+9
-3
src/Layout.tsx
+9
-3
src/Layout.tsx
···
2
2
3
3
interface LayoutProps {
4
4
children: React.ReactNode;
5
+
headerChart?: React.ReactNode;
5
6
}
6
7
7
-
export default function Layout({ children }: LayoutProps) {
8
+
export default function Layout({ children, headerChart }: LayoutProps) {
8
9
const location = useLocation();
9
10
const isTracksPage = location.pathname.startsWith("/tracks");
10
11
const isAlbumsPage = location.pathname.startsWith("/albums");
···
12
13
return (
13
14
<div className="min-h-screen bg-zinc-950 text-zinc-300 font-mono">
14
15
<div className="max-w-4xl mx-auto px-6 py-12">
15
-
<div className="mb-4 border-b border-zinc-800 pb-4">
16
-
<div className="flex items-end justify-between">
16
+
<div className="mb-4 border-b border-zinc-800 pb-4 relative">
17
+
{headerChart && (
18
+
<div className="absolute inset-0 pointer-events-none opacity-40">
19
+
{headerChart}
20
+
</div>
21
+
)}
22
+
<div className="flex items-end justify-between relative">
17
23
<div>
18
24
<h1 className="text-xs font-medium uppercase tracking-wider text-zinc-500">Listening History</h1>
19
25
<p className="text-xs text-zinc-600 mt-1">fm.teal.alpha.feed.play</p>
+47
-22
src/Profile.tsx
+47
-22
src/Profile.tsx
···
1
1
import { graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay";
2
2
import { useParams, Link } from "react-router-dom";
3
-
import { useEffect, useRef } from "react";
3
+
import { useEffect, useRef, useMemo } from "react";
4
4
import type { ProfileQuery as ProfileQueryType } from "./__generated__/ProfileQuery.graphql";
5
5
import type { Profile_plays$key } from "./__generated__/Profile_plays.graphql";
6
6
import TrackItem from "./TrackItem";
7
+
import ScrobbleChart from "./ScrobbleChart";
7
8
8
9
export default function Profile() {
9
10
const { handle } = useParams<{ handle: string }>();
10
11
12
+
const queryVariables = useMemo(() => {
13
+
// Round to start of day to keep timestamp stable
14
+
const now = new Date();
15
+
now.setHours(0, 0, 0, 0);
16
+
const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
17
+
18
+
return {
19
+
where: { actorHandle: { eq: handle } },
20
+
chartWhere: {
21
+
actorHandle: { eq: handle },
22
+
playedTime: {
23
+
gte: ninetyDaysAgo.toISOString(),
24
+
},
25
+
},
26
+
};
27
+
}, [handle]);
28
+
11
29
const queryData = useLazyLoadQuery<ProfileQueryType>(
12
30
graphql`
13
-
query ProfileQuery($where: FmTealAlphaFeedPlayWhereInput!) {
31
+
query ProfileQuery($where: FmTealAlphaFeedPlayWhereInput!, $chartWhere: FmTealAlphaFeedPlayWhereInput!) {
14
32
...Profile_plays @arguments(where: $where)
33
+
...ScrobbleChart_data
15
34
}
16
35
`,
17
-
{
18
-
where: { actorHandle: { eq: handle } },
19
-
}
36
+
queryVariables
20
37
);
21
38
22
39
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment<
···
34
51
fmTealAlphaFeedPlays(
35
52
first: $count
36
53
after: $cursor
37
-
sortBy: [{ field: "playedTime", direction: desc }]
54
+
sortBy: [{ field: playedTime, direction: desc }]
38
55
where: $where
39
56
)
40
57
@connection(
···
63
80
64
81
const loadMoreRef = useRef<HTMLDivElement>(null);
65
82
66
-
const plays = data?.fmTealAlphaFeedPlays?.edges?.map((edge) => edge.node).filter((n) => n != null) || [];
83
+
const plays = useMemo(
84
+
() => data?.fmTealAlphaFeedPlays?.edges?.map((edge) => edge.node).filter((n) => n != null) || [],
85
+
[data?.fmTealAlphaFeedPlays?.edges]
86
+
);
67
87
const profile = plays?.[0]?.appBskyActorProfile;
68
88
69
89
useEffect(() => {
···
97
117
← Back
98
118
</Link>
99
119
100
-
<div className="mb-12 flex items-start gap-6 border-b border-zinc-800 pb-6">
101
-
{profile?.avatar?.url && (
102
-
<img
103
-
src={profile.avatar.url}
104
-
alt={profile.displayName ?? handle ?? "User"}
105
-
className="w-16 h-16 flex-shrink-0 object-cover"
106
-
/>
107
-
)}
108
-
<div className="flex-1">
109
-
<h1 className="text-lg font-medium mb-1 text-zinc-100">
110
-
{profile?.displayName ?? handle}
111
-
</h1>
112
-
<p className="text-xs text-zinc-500 mb-2">@{handle}</p>
113
-
{profile?.description && (
114
-
<p className="text-xs text-zinc-400">{profile.description}</p>
120
+
<div className="mb-12 border-b border-zinc-800 pb-6 relative">
121
+
<div className="absolute inset-0 pointer-events-none opacity-40">
122
+
<ScrobbleChart queryRef={queryData} />
123
+
</div>
124
+
<div className="relative flex items-start gap-6">
125
+
{profile?.avatar?.url && (
126
+
<img
127
+
src={profile.avatar.url}
128
+
alt={profile.displayName ?? handle ?? "User"}
129
+
className="w-16 h-16 flex-shrink-0 object-cover"
130
+
/>
115
131
)}
132
+
<div className="flex-1">
133
+
<h1 className="text-lg font-medium mb-1 text-zinc-100">
134
+
{profile?.displayName ?? handle}
135
+
</h1>
136
+
<p className="text-xs text-zinc-500 mb-2">@{handle}</p>
137
+
{profile?.description && (
138
+
<p className="text-xs text-zinc-400">{profile.description}</p>
139
+
)}
140
+
</div>
116
141
</div>
117
142
</div>
118
143
+113
src/ScrobbleChart.tsx
+113
src/ScrobbleChart.tsx
···
1
+
import { graphql, useFragment } from "react-relay";
2
+
import { useMemo } from "react";
3
+
import type { ScrobbleChart_data$key } from "./__generated__/ScrobbleChart_data.graphql";
4
+
5
+
interface ScrobbleChartProps {
6
+
queryRef: ScrobbleChart_data$key;
7
+
}
8
+
9
+
export default function ScrobbleChart({ queryRef }: ScrobbleChartProps) {
10
+
const data = useFragment(
11
+
graphql`
12
+
fragment ScrobbleChart_data on Query {
13
+
chartData: fmTealAlphaFeedPlaysAggregated(
14
+
groupBy: [{ field: playedTime, interval: day }]
15
+
where: $chartWhere
16
+
limit: 90
17
+
) {
18
+
playedTime
19
+
count
20
+
}
21
+
}
22
+
`,
23
+
queryRef
24
+
);
25
+
26
+
const chartData = useMemo(() => {
27
+
if (!data?.chartData) return [];
28
+
29
+
// Convert aggregated data to chart format
30
+
const aggregated = data.chartData.map((item) => {
31
+
// playedTime comes back as '2025-08-03 00:00:00', extract just the date part
32
+
const date = item.playedTime ? item.playedTime.split(' ')[0] : "";
33
+
return {
34
+
date,
35
+
count: item.count,
36
+
};
37
+
}).sort((a, b) => a.date.localeCompare(b.date));
38
+
39
+
// Fill in missing days with zero counts
40
+
const now = new Date();
41
+
now.setHours(0, 0, 0, 0);
42
+
const filledData = [];
43
+
44
+
for (let i = 89; i >= 0; i--) {
45
+
const date = new Date(now);
46
+
date.setDate(date.getDate() - i);
47
+
const dateStr = date.toISOString().split("T")[0];
48
+
49
+
const existing = aggregated.find((d) => d.date === dateStr);
50
+
filledData.push({
51
+
date: dateStr,
52
+
count: existing ? existing.count : 0,
53
+
});
54
+
}
55
+
56
+
return filledData;
57
+
}, [data?.chartData]);
58
+
59
+
if (!chartData || chartData.length === 0) return null;
60
+
61
+
const width = 1000;
62
+
const height = 100;
63
+
const padding = { top: 0, right: 0, bottom: 0, left: 0 };
64
+
const chartWidth = width - padding.left - padding.right;
65
+
const chartHeight = height - padding.top - padding.bottom;
66
+
67
+
const maxCount = Math.max(...chartData.map((d) => d.count));
68
+
const minCount = Math.min(...chartData.map((d) => d.count));
69
+
const range = maxCount - minCount || 1;
70
+
71
+
// Generate points for the line
72
+
const points = chartData.map((d, i) => {
73
+
const x = padding.left + (i / (chartData.length - 1)) * chartWidth;
74
+
const y = padding.top + chartHeight - ((d.count - minCount) / range) * chartHeight;
75
+
return `${x},${y}`;
76
+
}).join(" ");
77
+
78
+
// Generate area path
79
+
const areaPoints = [
80
+
`${padding.left},${padding.top + chartHeight}`,
81
+
...chartData.map((d, i) => {
82
+
const x = padding.left + (i / (chartData.length - 1)) * chartWidth;
83
+
const y = padding.top + chartHeight - ((d.count - minCount) / range) * chartHeight;
84
+
return `${x},${y}`;
85
+
}),
86
+
`${padding.left + chartWidth},${padding.top + chartHeight}`,
87
+
].join(" ");
88
+
89
+
return (
90
+
<svg
91
+
viewBox={`0 0 ${width} ${height}`}
92
+
className="w-full h-full"
93
+
preserveAspectRatio="none"
94
+
>
95
+
{/* Area fill */}
96
+
<polygon
97
+
points={areaPoints}
98
+
fill="rgb(139 92 246 / 0.1)"
99
+
stroke="none"
100
+
/>
101
+
102
+
{/* Line */}
103
+
<polyline
104
+
points={points}
105
+
fill="none"
106
+
stroke="rgb(139 92 246)"
107
+
strokeWidth="1.5"
108
+
strokeLinecap="round"
109
+
strokeLinejoin="round"
110
+
/>
111
+
</svg>
112
+
);
113
+
}
+1
-1
src/TopAlbums.tsx
+1
-1
src/TopAlbums.tsx
···
13
13
graphql`
14
14
query TopAlbumsQuery($where: FmTealAlphaFeedPlayWhereInput) {
15
15
fmTealAlphaFeedPlaysAggregated(
16
-
groupBy: [releaseMbId, releaseName, artists]
16
+
groupBy: [{ field: releaseMbId }, { field: releaseName }, { field: artists }]
17
17
orderBy: { count: desc }
18
18
limit: 100
19
19
where: $where
+1
-1
src/TopTracks.tsx
+1
-1
src/TopTracks.tsx
···
13
13
graphql`
14
14
query TopTracksQuery($where: FmTealAlphaFeedPlayWhereInput) {
15
15
fmTealAlphaFeedPlaysAggregated(
16
-
groupBy: [trackName, releaseMbId, artists]
16
+
groupBy: [{ field: trackName }, { field: releaseMbId }, { field: artists }]
17
17
orderBy: { count: desc }
18
18
limit: 50
19
19
where: $where
+4
-4
src/__generated__/AppPaginationQuery.graphql.ts
+4
-4
src/__generated__/AppPaginationQuery.graphql.ts
···
1
1
/**
2
-
* @generated SignedSource<<93c3b304b5d8458925d44479b7dfa204>>
2
+
* @generated SignedSource<<38f32c1e1448eb48251113a07e781789>>
3
3
* @lightSyntaxTransform
4
4
* @nogrep
5
5
*/
···
250
250
]
251
251
},
252
252
"params": {
253
-
"cacheID": "71c3d1d480a2c2bc60d3b35d2f07d4eb",
253
+
"cacheID": "cb24b99f8b849bdfc642275cfd4df3fe",
254
254
"id": null,
255
255
"metadata": {},
256
256
"name": "AppPaginationQuery",
257
257
"operationKind": "query",
258
-
"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"
258
+
"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"
259
259
}
260
260
};
261
261
})();
262
262
263
-
(node as any).hash = "0e4acf96fedae07af90ce6e9e3bf18d6";
263
+
(node as any).hash = "1e73fa97ccff20071e5a3fba0f00b48c";
264
264
265
265
export default node;
+119
-18
src/__generated__/AppQuery.graphql.ts
+119
-18
src/__generated__/AppQuery.graphql.ts
···
1
1
/**
2
-
* @generated SignedSource<<260fc65cac40538ad1a1673377c9e51d>>
2
+
* @generated SignedSource<<bd4d57eff6a192efe2535389231fe37e>>
3
3
* @lightSyntaxTransform
4
4
* @nogrep
5
5
*/
···
10
10
11
11
import { ConcreteRequest } from 'relay-runtime';
12
12
import { FragmentRefs } from "relay-runtime";
13
-
export type AppQuery$variables = Record<PropertyKey, never>;
13
+
export type FmTealAlphaFeedPlayWhereInput = {
14
+
actorHandle?: StringFilter | null | undefined;
15
+
artistMbIds?: StringFilter | null | undefined;
16
+
artistNames?: StringFilter | null | undefined;
17
+
artists?: StringFilter | null | undefined;
18
+
cid?: StringFilter | null | undefined;
19
+
collection?: StringFilter | null | undefined;
20
+
did?: StringFilter | null | undefined;
21
+
duration?: IntFilter | null | undefined;
22
+
indexedAt?: DateTimeFilter | null | undefined;
23
+
isrc?: StringFilter | null | undefined;
24
+
musicServiceBaseDomain?: StringFilter | null | undefined;
25
+
originUrl?: StringFilter | null | undefined;
26
+
playedTime?: StringFilter | null | undefined;
27
+
recordingMbId?: StringFilter | null | undefined;
28
+
releaseMbId?: StringFilter | null | undefined;
29
+
releaseName?: StringFilter | null | undefined;
30
+
submissionClientAgent?: StringFilter | null | undefined;
31
+
trackMbId?: StringFilter | null | undefined;
32
+
trackName?: StringFilter | null | undefined;
33
+
uri?: StringFilter | null | undefined;
34
+
};
35
+
export type DateTimeFilter = {
36
+
eq?: string | null | undefined;
37
+
gt?: string | null | undefined;
38
+
gte?: string | null | undefined;
39
+
lt?: string | null | undefined;
40
+
lte?: string | null | undefined;
41
+
};
42
+
export type StringFilter = {
43
+
contains?: string | null | undefined;
44
+
eq?: string | null | undefined;
45
+
gt?: string | null | undefined;
46
+
gte?: string | null | undefined;
47
+
in?: ReadonlyArray<string | null | undefined> | null | undefined;
48
+
lt?: string | null | undefined;
49
+
lte?: string | null | undefined;
50
+
};
51
+
export type IntFilter = {
52
+
eq?: number | null | undefined;
53
+
gt?: number | null | undefined;
54
+
gte?: number | null | undefined;
55
+
in?: ReadonlyArray<number | null | undefined> | null | undefined;
56
+
lt?: number | null | undefined;
57
+
lte?: number | null | undefined;
58
+
};
59
+
export type AppQuery$variables = {
60
+
chartWhere: FmTealAlphaFeedPlayWhereInput;
61
+
};
14
62
export type AppQuery$data = {
15
-
readonly " $fragmentSpreads": FragmentRefs<"App_plays">;
63
+
readonly " $fragmentSpreads": FragmentRefs<"App_plays" | "ScrobbleChart_data">;
16
64
};
17
65
export type AppQuery = {
18
66
response: AppQuery$data;
···
21
69
22
70
const node: ConcreteRequest = (function(){
23
71
var v0 = [
72
+
{
73
+
"defaultValue": null,
74
+
"kind": "LocalArgument",
75
+
"name": "chartWhere"
76
+
}
77
+
],
78
+
v1 = [
24
79
{
25
80
"kind": "Literal",
26
81
"name": "first",
···
36
91
}
37
92
]
38
93
}
39
-
];
94
+
],
95
+
v2 = {
96
+
"alias": null,
97
+
"args": null,
98
+
"kind": "ScalarField",
99
+
"name": "playedTime",
100
+
"storageKey": null
101
+
};
40
102
return {
41
103
"fragment": {
42
-
"argumentDefinitions": [],
104
+
"argumentDefinitions": (v0/*: any*/),
43
105
"kind": "Fragment",
44
106
"metadata": null,
45
107
"name": "AppQuery",
···
48
110
"args": null,
49
111
"kind": "FragmentSpread",
50
112
"name": "App_plays"
113
+
},
114
+
{
115
+
"args": null,
116
+
"kind": "FragmentSpread",
117
+
"name": "ScrobbleChart_data"
51
118
}
52
119
],
53
120
"type": "Query",
···
55
122
},
56
123
"kind": "Request",
57
124
"operation": {
58
-
"argumentDefinitions": [],
125
+
"argumentDefinitions": (v0/*: any*/),
59
126
"kind": "Operation",
60
127
"name": "AppQuery",
61
128
"selections": [
62
129
{
63
130
"alias": null,
64
-
"args": (v0/*: any*/),
131
+
"args": (v1/*: any*/),
65
132
"concreteType": "FmTealAlphaFeedPlayConnection",
66
133
"kind": "LinkedField",
67
134
"name": "fmTealAlphaFeedPlays",
···
90
157
"name": "node",
91
158
"plural": false,
92
159
"selections": [
93
-
{
94
-
"alias": null,
95
-
"args": null,
96
-
"kind": "ScalarField",
97
-
"name": "playedTime",
98
-
"storageKey": null
99
-
},
160
+
(v2/*: any*/),
100
161
{
101
162
"alias": null,
102
163
"args": null,
···
207
268
},
208
269
{
209
270
"alias": null,
210
-
"args": (v0/*: any*/),
271
+
"args": (v1/*: any*/),
211
272
"filters": [
212
273
"sortBy"
213
274
],
···
215
276
"key": "App_fmTealAlphaFeedPlays",
216
277
"kind": "LinkedHandle",
217
278
"name": "fmTealAlphaFeedPlays"
279
+
},
280
+
{
281
+
"alias": "chartData",
282
+
"args": [
283
+
{
284
+
"kind": "Literal",
285
+
"name": "groupBy",
286
+
"value": [
287
+
{
288
+
"field": "playedTime",
289
+
"interval": "day"
290
+
}
291
+
]
292
+
},
293
+
{
294
+
"kind": "Literal",
295
+
"name": "limit",
296
+
"value": 90
297
+
},
298
+
{
299
+
"kind": "Variable",
300
+
"name": "where",
301
+
"variableName": "chartWhere"
302
+
}
303
+
],
304
+
"concreteType": "FmTealAlphaFeedPlayAggregated",
305
+
"kind": "LinkedField",
306
+
"name": "fmTealAlphaFeedPlaysAggregated",
307
+
"plural": true,
308
+
"selections": [
309
+
(v2/*: any*/),
310
+
{
311
+
"alias": null,
312
+
"args": null,
313
+
"kind": "ScalarField",
314
+
"name": "count",
315
+
"storageKey": null
316
+
}
317
+
],
318
+
"storageKey": null
218
319
}
219
320
]
220
321
},
221
322
"params": {
222
-
"cacheID": "f3173a0a17eece6a35b00c37e787c484",
323
+
"cacheID": "038b79e3af13c34df9bfca055c5f7829",
223
324
"id": null,
224
325
"metadata": {},
225
326
"name": "AppQuery",
226
327
"operationKind": "query",
227
-
"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"
328
+
"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"
228
329
}
229
330
};
230
331
})();
231
332
232
-
(node as any).hash = "4b1837f6cd874e31461fbead77c1b012";
333
+
(node as any).hash = "7266612861cb55b740623549f1a03f26";
233
334
234
335
export default node;
+2
-2
src/__generated__/App_plays.graphql.ts
+2
-2
src/__generated__/App_plays.graphql.ts
···
1
1
/**
2
-
* @generated SignedSource<<a3ae5f31f618986fb12e6c57458c9853>>
2
+
* @generated SignedSource<<ba0bacb4e016f0edbea67013c8694b23>>
3
3
* @lightSyntaxTransform
4
4
* @nogrep
5
5
*/
···
179
179
};
180
180
})();
181
181
182
-
(node as any).hash = "0e4acf96fedae07af90ce6e9e3bf18d6";
182
+
(node as any).hash = "1e73fa97ccff20071e5a3fba0f00b48c";
183
183
184
184
export default node;
+4
-4
src/__generated__/ProfilePaginationQuery.graphql.ts
+4
-4
src/__generated__/ProfilePaginationQuery.graphql.ts
···
1
1
/**
2
-
* @generated SignedSource<<9824b6fa6724ec81721b89464e18ee4f>>
2
+
* @generated SignedSource<<05e7a8d8804cbbe062ff7dce37522623>>
3
3
* @lightSyntaxTransform
4
4
* @nogrep
5
5
*/
···
341
341
]
342
342
},
343
343
"params": {
344
-
"cacheID": "6d52a3e02fe71c3ad54ec2006fc2ac45",
344
+
"cacheID": "72ce84bf8cc8ac016e19ca462a2f7b70",
345
345
"id": null,
346
346
"metadata": {},
347
347
"name": "ProfilePaginationQuery",
348
348
"operationKind": "query",
349
-
"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"
349
+
"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"
350
350
}
351
351
};
352
352
})();
353
353
354
-
(node as any).hash = "86bf47e8cb24c938b0b5d7ad6f5cb916";
354
+
(node as any).hash = "fb9d67e8cd94c4191b9956225ff78bdf";
355
355
356
356
export default node;
+83
-27
src/__generated__/ProfileQuery.graphql.ts
+83
-27
src/__generated__/ProfileQuery.graphql.ts
···
1
1
/**
2
-
* @generated SignedSource<<0920331f4eccd3551cbc3ca8646596f0>>
2
+
* @generated SignedSource<<8d62f515b7652094345304e43124aa72>>
3
3
* @lightSyntaxTransform
4
4
* @nogrep
5
5
*/
···
57
57
lte?: number | null | undefined;
58
58
};
59
59
export type ProfileQuery$variables = {
60
+
chartWhere: FmTealAlphaFeedPlayWhereInput;
60
61
where: FmTealAlphaFeedPlayWhereInput;
61
62
};
62
63
export type ProfileQuery$data = {
63
-
readonly " $fragmentSpreads": FragmentRefs<"Profile_plays">;
64
+
readonly " $fragmentSpreads": FragmentRefs<"Profile_plays" | "ScrobbleChart_data">;
64
65
};
65
66
export type ProfileQuery = {
66
67
response: ProfileQuery$data;
···
68
69
};
69
70
70
71
const node: ConcreteRequest = (function(){
71
-
var v0 = [
72
-
{
73
-
"defaultValue": null,
74
-
"kind": "LocalArgument",
75
-
"name": "where"
76
-
}
77
-
],
72
+
var v0 = {
73
+
"defaultValue": null,
74
+
"kind": "LocalArgument",
75
+
"name": "chartWhere"
76
+
},
78
77
v1 = {
78
+
"defaultValue": null,
79
+
"kind": "LocalArgument",
80
+
"name": "where"
81
+
},
82
+
v2 = {
79
83
"kind": "Variable",
80
84
"name": "where",
81
85
"variableName": "where"
82
86
},
83
-
v2 = [
87
+
v3 = [
84
88
{
85
89
"kind": "Literal",
86
90
"name": "first",
···
96
100
}
97
101
]
98
102
},
99
-
(v1/*: any*/)
100
-
];
103
+
(v2/*: any*/)
104
+
],
105
+
v4 = {
106
+
"alias": null,
107
+
"args": null,
108
+
"kind": "ScalarField",
109
+
"name": "playedTime",
110
+
"storageKey": null
111
+
};
101
112
return {
102
113
"fragment": {
103
-
"argumentDefinitions": (v0/*: any*/),
114
+
"argumentDefinitions": [
115
+
(v0/*: any*/),
116
+
(v1/*: any*/)
117
+
],
104
118
"kind": "Fragment",
105
119
"metadata": null,
106
120
"name": "ProfileQuery",
107
121
"selections": [
108
122
{
109
123
"args": [
110
-
(v1/*: any*/)
124
+
(v2/*: any*/)
111
125
],
112
126
"kind": "FragmentSpread",
113
127
"name": "Profile_plays"
128
+
},
129
+
{
130
+
"args": null,
131
+
"kind": "FragmentSpread",
132
+
"name": "ScrobbleChart_data"
114
133
}
115
134
],
116
135
"type": "Query",
···
118
137
},
119
138
"kind": "Request",
120
139
"operation": {
121
-
"argumentDefinitions": (v0/*: any*/),
140
+
"argumentDefinitions": [
141
+
(v1/*: any*/),
142
+
(v0/*: any*/)
143
+
],
122
144
"kind": "Operation",
123
145
"name": "ProfileQuery",
124
146
"selections": [
125
147
{
126
148
"alias": null,
127
-
"args": (v2/*: any*/),
149
+
"args": (v3/*: any*/),
128
150
"concreteType": "FmTealAlphaFeedPlayConnection",
129
151
"kind": "LinkedField",
130
152
"name": "fmTealAlphaFeedPlays",
···
160
182
"name": "trackName",
161
183
"storageKey": null
162
184
},
163
-
{
164
-
"alias": null,
165
-
"args": null,
166
-
"kind": "ScalarField",
167
-
"name": "playedTime",
168
-
"storageKey": null
169
-
},
185
+
(v4/*: any*/),
170
186
{
171
187
"alias": null,
172
188
"args": null,
···
301
317
},
302
318
{
303
319
"alias": null,
304
-
"args": (v2/*: any*/),
320
+
"args": (v3/*: any*/),
305
321
"filters": [
306
322
"where",
307
323
"sortBy"
···
310
326
"key": "Profile_fmTealAlphaFeedPlays",
311
327
"kind": "LinkedHandle",
312
328
"name": "fmTealAlphaFeedPlays"
329
+
},
330
+
{
331
+
"alias": "chartData",
332
+
"args": [
333
+
{
334
+
"kind": "Literal",
335
+
"name": "groupBy",
336
+
"value": [
337
+
{
338
+
"field": "playedTime",
339
+
"interval": "day"
340
+
}
341
+
]
342
+
},
343
+
{
344
+
"kind": "Literal",
345
+
"name": "limit",
346
+
"value": 90
347
+
},
348
+
{
349
+
"kind": "Variable",
350
+
"name": "where",
351
+
"variableName": "chartWhere"
352
+
}
353
+
],
354
+
"concreteType": "FmTealAlphaFeedPlayAggregated",
355
+
"kind": "LinkedField",
356
+
"name": "fmTealAlphaFeedPlaysAggregated",
357
+
"plural": true,
358
+
"selections": [
359
+
(v4/*: any*/),
360
+
{
361
+
"alias": null,
362
+
"args": null,
363
+
"kind": "ScalarField",
364
+
"name": "count",
365
+
"storageKey": null
366
+
}
367
+
],
368
+
"storageKey": null
313
369
}
314
370
]
315
371
},
316
372
"params": {
317
-
"cacheID": "0592d86db07d88ab11657ea4cd107231",
373
+
"cacheID": "083f03714f183a8e68ff0a6d15f0d757",
318
374
"id": null,
319
375
"metadata": {},
320
376
"name": "ProfileQuery",
321
377
"operationKind": "query",
322
-
"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"
378
+
"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"
323
379
}
324
380
};
325
381
})();
326
382
327
-
(node as any).hash = "4a0ecbad0ab4453246cdcdd753b1f84f";
383
+
(node as any).hash = "e000bb0fb9935e8e853d847c4362ffe6";
328
384
329
385
export default node;
+2
-2
src/__generated__/Profile_plays.graphql.ts
+2
-2
src/__generated__/Profile_plays.graphql.ts
···
1
1
/**
2
-
* @generated SignedSource<<0e69127350e3dc66273c2ea60929dc92>>
2
+
* @generated SignedSource<<9b9347661ace6bcbeb677e53e4b5feec>>
3
3
* @lightSyntaxTransform
4
4
* @nogrep
5
5
*/
···
245
245
};
246
246
})();
247
247
248
-
(node as any).hash = "86bf47e8cb24c938b0b5d7ad6f5cb916";
248
+
(node as any).hash = "fb9d67e8cd94c4191b9956225ff78bdf";
249
249
250
250
export default node;
+89
src/__generated__/ScrobbleChart_data.graphql.ts
+89
src/__generated__/ScrobbleChart_data.graphql.ts
···
1
+
/**
2
+
* @generated SignedSource<<7b446f8950ffde63fb0e7748bb596e66>>
3
+
* @lightSyntaxTransform
4
+
* @nogrep
5
+
*/
6
+
7
+
/* tslint:disable */
8
+
/* eslint-disable */
9
+
// @ts-nocheck
10
+
11
+
import { ReaderFragment } from 'relay-runtime';
12
+
import { FragmentRefs } from "relay-runtime";
13
+
export type ScrobbleChart_data$data = {
14
+
readonly chartData: ReadonlyArray<{
15
+
readonly count: number;
16
+
readonly playedTime: any | null | undefined;
17
+
}>;
18
+
readonly " $fragmentType": "ScrobbleChart_data";
19
+
};
20
+
export type ScrobbleChart_data$key = {
21
+
readonly " $data"?: ScrobbleChart_data$data;
22
+
readonly " $fragmentSpreads": FragmentRefs<"ScrobbleChart_data">;
23
+
};
24
+
25
+
const node: ReaderFragment = {
26
+
"argumentDefinitions": [
27
+
{
28
+
"kind": "RootArgument",
29
+
"name": "chartWhere"
30
+
}
31
+
],
32
+
"kind": "Fragment",
33
+
"metadata": null,
34
+
"name": "ScrobbleChart_data",
35
+
"selections": [
36
+
{
37
+
"alias": "chartData",
38
+
"args": [
39
+
{
40
+
"kind": "Literal",
41
+
"name": "groupBy",
42
+
"value": [
43
+
{
44
+
"field": "playedTime",
45
+
"interval": "day"
46
+
}
47
+
]
48
+
},
49
+
{
50
+
"kind": "Literal",
51
+
"name": "limit",
52
+
"value": 90
53
+
},
54
+
{
55
+
"kind": "Variable",
56
+
"name": "where",
57
+
"variableName": "chartWhere"
58
+
}
59
+
],
60
+
"concreteType": "FmTealAlphaFeedPlayAggregated",
61
+
"kind": "LinkedField",
62
+
"name": "fmTealAlphaFeedPlaysAggregated",
63
+
"plural": true,
64
+
"selections": [
65
+
{
66
+
"alias": null,
67
+
"args": null,
68
+
"kind": "ScalarField",
69
+
"name": "playedTime",
70
+
"storageKey": null
71
+
},
72
+
{
73
+
"alias": null,
74
+
"args": null,
75
+
"kind": "ScalarField",
76
+
"name": "count",
77
+
"storageKey": null
78
+
}
79
+
],
80
+
"storageKey": null
81
+
}
82
+
],
83
+
"type": "Query",
84
+
"abstractKey": null
85
+
};
86
+
87
+
(node as any).hash = "6d8ebfa533779947a0b3cd703929b5ba";
88
+
89
+
export default node;
+13
-7
src/__generated__/TopAlbumsQuery.graphql.ts
+13
-7
src/__generated__/TopAlbumsQuery.graphql.ts
···
1
1
/**
2
-
* @generated SignedSource<<8cf8cec6835334168002a2939635c9d5>>
2
+
* @generated SignedSource<<ba0a977fd251b8099185f84ffca5fe7f>>
3
3
* @lightSyntaxTransform
4
4
* @nogrep
5
5
*/
···
87
87
"kind": "Literal",
88
88
"name": "groupBy",
89
89
"value": [
90
-
"releaseMbId",
91
-
"releaseName",
92
-
"artists"
90
+
{
91
+
"field": "releaseMbId"
92
+
},
93
+
{
94
+
"field": "releaseName"
95
+
},
96
+
{
97
+
"field": "artists"
98
+
}
93
99
]
94
100
},
95
101
{
···
165
171
"selections": (v1/*: any*/)
166
172
},
167
173
"params": {
168
-
"cacheID": "bb227295300710370e7e5c2492532c01",
174
+
"cacheID": "4bc742f9cab572a86f4956ae1325e650",
169
175
"id": null,
170
176
"metadata": {},
171
177
"name": "TopAlbumsQuery",
172
178
"operationKind": "query",
173
-
"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"
179
+
"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"
174
180
}
175
181
};
176
182
})();
177
183
178
-
(node as any).hash = "6e30827615eb8acfde3c0c80598b6627";
184
+
(node as any).hash = "c916cfe287c6837e7b40f0712b123f12";
179
185
180
186
export default node;
+13
-7
src/__generated__/TopTracksQuery.graphql.ts
+13
-7
src/__generated__/TopTracksQuery.graphql.ts
···
1
1
/**
2
-
* @generated SignedSource<<2c2f4cf7a049eff39002109dffd04288>>
2
+
* @generated SignedSource<<d82ad2bc23a12ef33ba6bce1df9620f3>>
3
3
* @lightSyntaxTransform
4
4
* @nogrep
5
5
*/
···
87
87
"kind": "Literal",
88
88
"name": "groupBy",
89
89
"value": [
90
-
"trackName",
91
-
"releaseMbId",
92
-
"artists"
90
+
{
91
+
"field": "trackName"
92
+
},
93
+
{
94
+
"field": "releaseMbId"
95
+
},
96
+
{
97
+
"field": "artists"
98
+
}
93
99
]
94
100
},
95
101
{
···
165
171
"selections": (v1/*: any*/)
166
172
},
167
173
"params": {
168
-
"cacheID": "cbf23694acc55e8dfbe9296500193932",
174
+
"cacheID": "d889d685b64fb19d468954bb3fb7ff7c",
169
175
"id": null,
170
176
"metadata": {},
171
177
"name": "TopTracksQuery",
172
178
"operationKind": "query",
173
-
"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"
179
+
"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"
174
180
}
175
181
};
176
182
})();
177
183
178
-
(node as any).hash = "6b649e9c39df41d4ce995e69e6dc6f35";
184
+
(node as any).hash = "4b62eaeaf8a935abc28e77c8cd2907d1";
179
185
180
186
export default node;
+37
src/generateChartData.ts
+37
src/generateChartData.ts
···
1
+
export interface DataPoint {
2
+
date: string;
3
+
count: number;
4
+
}
5
+
6
+
export function generateChartData(
7
+
plays: readonly { readonly playedTime?: string | null; readonly [key: string]: any }[],
8
+
days = 90
9
+
): DataPoint[] {
10
+
const counts = new Map<string, number>();
11
+
const now = new Date();
12
+
13
+
// Initialize last N days with 0 counts
14
+
for (let i = days - 1; i >= 0; i--) {
15
+
const date = new Date(now);
16
+
date.setDate(date.getDate() - i);
17
+
date.setHours(0, 0, 0, 0);
18
+
const dateStr = date.toISOString().split("T")[0];
19
+
counts.set(dateStr, 0);
20
+
}
21
+
22
+
// Count plays per day
23
+
plays.forEach((play) => {
24
+
if (play?.playedTime) {
25
+
const date = new Date(play.playedTime);
26
+
date.setHours(0, 0, 0, 0);
27
+
const dateStr = date.toISOString().split("T")[0];
28
+
if (counts.has(dateStr)) {
29
+
counts.set(dateStr, (counts.get(dateStr) || 0) + 1);
30
+
}
31
+
}
32
+
});
33
+
34
+
return Array.from(counts.entries())
35
+
.map(([date, count]) => ({ date, count }))
36
+
.sort((a, b) => a.date.localeCompare(b.date));
37
+
}