Demo using Slices Network GraphQL Relay API to make a teal.fm client

add scrobble charts and more top categories

+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 + }