forked from
chadtmiller.com/slices-teal-relay
Teal.fm frontend powered by slices.network
tealfm-slices.wisp.place
tealfm
slices
1import {
2 graphql,
3 useLazyLoadQuery,
4 usePaginationFragment,
5 useSubscription,
6} from "react-relay";
7import { useEffect, useRef, useMemo } from "react";
8import type { AppQuery } from "./__generated__/AppQuery.graphql";
9import type { App_plays$key } from "./__generated__/App_plays.graphql";
10import type { AppSubscription } from "./__generated__/AppSubscription.graphql";
11import TrackItem from "./TrackItem";
12import Layout from "./Layout";
13import ScrobbleChart from "./ScrobbleChart";
14import {
15 ConnectionHandler,
16 type GraphQLSubscriptionConfig,
17} from "relay-runtime";
18
19export 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
35 const queryData = useLazyLoadQuery<AppQuery>(
36 graphql`
37 query AppQuery($chartWhere: FmTealAlphaFeedPlayWhereInput!) {
38 ...App_plays
39 ...ScrobbleChart_data
40 }
41 `,
42 queryVariables
43 );
44
45 const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment<
46 AppQuery,
47 App_plays$key
48 >(
49 graphql`
50 fragment App_plays on Query
51 @refetchable(queryName: "AppPaginationQuery")
52 @argumentDefinitions(
53 cursor: { type: "String" }
54 count: { type: "Int", defaultValue: 20 }
55 ) {
56 fmTealAlphaFeedPlays(
57 first: $count
58 after: $cursor
59 sortBy: [{ field: playedTime, direction: desc }]
60 ) @connection(key: "App_fmTealAlphaFeedPlays", filters: ["sortBy"]) {
61 totalCount
62 edges {
63 node {
64 playedTime
65 ...TrackItem_play
66 }
67 }
68 }
69 }
70 `,
71 queryData
72 );
73
74 const loadMoreRef = useRef<HTMLDivElement>(null);
75
76 // Subscribe to new plays
77 const subscriptionConfig: GraphQLSubscriptionConfig<AppSubscription> = {
78 subscription: graphql`
79 subscription AppSubscription {
80 fmTealAlphaFeedPlayCreated {
81 uri
82 playedTime
83 ...TrackItem_play
84 }
85 }
86 `,
87 variables: {},
88 updater: (store) => {
89 const newPlay = store.getRootField("fmTealAlphaFeedPlayCreated");
90 if (!newPlay) return;
91
92 const root = store.getRoot();
93 const connection = ConnectionHandler.getConnection(
94 root,
95 "App_fmTealAlphaFeedPlays",
96 { sortBy: [{ field: "playedTime", direction: "desc" }] }
97 );
98
99 if (!connection) return;
100
101 const edge = ConnectionHandler.createEdge(
102 store,
103 connection,
104 newPlay,
105 "FmTealAlphaFeedPlayEdge"
106 );
107
108 ConnectionHandler.insertEdgeBefore(connection, edge);
109
110 // Update totalCount
111 const totalCountRecord = root.getLinkedRecord("fmTealAlphaFeedPlays", {
112 sortBy: [{ field: "playedTime", direction: "desc" }],
113 });
114 if (totalCountRecord) {
115 const currentCount = totalCountRecord.getValue("totalCount") as number;
116 if (typeof currentCount === "number") {
117 totalCountRecord.setValue(currentCount + 1, "totalCount");
118 }
119 }
120 },
121 };
122
123 useSubscription(subscriptionConfig);
124
125 useEffect(() => {
126 window.scrollTo(0, 0);
127 }, []);
128
129 const plays =
130 data?.fmTealAlphaFeedPlays?.edges
131 ?.map((edge) => edge.node)
132 .filter((n) => n != null) || [];
133
134 useEffect(() => {
135 if (!loadMoreRef.current || !hasNext) return;
136
137 const observer = new IntersectionObserver(
138 (entries) => {
139 if (entries[0].isIntersecting && hasNext && !isLoadingNext) {
140 loadNext(20);
141 }
142 },
143 { threshold: 0.1 }
144 );
145
146 observer.observe(loadMoreRef.current);
147
148 return () => observer.disconnect();
149 }, [hasNext, isLoadingNext, loadNext]);
150
151 // Group plays by date
152 const groupedPlays: { date: string; plays: typeof plays }[] = [];
153 let currentDate = "";
154
155 plays.forEach((play) => {
156 if (!play?.playedTime) return;
157
158 const playDate = new Date(play.playedTime).toLocaleDateString("en-US", {
159 weekday: "long",
160 day: "numeric",
161 month: "long",
162 year: "numeric",
163 });
164
165 if (playDate !== currentDate) {
166 currentDate = playDate;
167 groupedPlays.push({ date: playDate, plays: [play] });
168 } else {
169 groupedPlays[groupedPlays.length - 1].plays.push(play);
170 }
171 });
172
173 return (
174 <Layout headerChart={<ScrobbleChart queryRef={queryData} />}>
175 <div className="mb-8">
176 <p className="text-xs text-zinc-500 uppercase tracking-wider">
177 {data?.fmTealAlphaFeedPlays?.totalCount?.toLocaleString()} scrobbles
178 </p>
179 </div>
180
181 <div>
182 {groupedPlays.map((group, groupIndex) => (
183 <div key={groupIndex} className="mb-12">
184 <h2 className="text-xs text-zinc-600 font-medium mb-6 uppercase tracking-wider">
185 {group.date}
186 </h2>
187 <div className="space-y-1">
188 {group.plays.map((play, index) => (
189 <TrackItem key={index} play={play} />
190 ))}
191 </div>
192 </div>
193 ))}
194 </div>
195
196 {hasNext && (
197 <div ref={loadMoreRef} className="py-12 text-center">
198 {isLoadingNext ? (
199 <p className="text-xs text-zinc-600 uppercase tracking-wider">
200 Loading...
201 </p>
202 ) : (
203 <p className="text-xs text-zinc-700 uppercase tracking-wider">·</p>
204 )}
205 </div>
206 )}
207 </Layout>
208 );
209}