Skip to content

Commit de88993

Browse files
authored
Merge pull request #379 from hackdays-io/issue/317
アシストクレジットや分配率の可視化を通して、貢献が見えるような体験を実装
2 parents 1ba9be7 + a303169 commit de88993

File tree

3 files changed

+342
-2
lines changed

3 files changed

+342
-2
lines changed
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import {
2+
Box,
3+
Button,
4+
Flex,
5+
Grid,
6+
HStack,
7+
Text,
8+
VStack,
9+
} from "@chakra-ui/react";
10+
import { Link } from "@remix-run/react";
11+
import { OrderDirection, TransferFractionToken_OrderBy } from "gql/graphql";
12+
import { useNamesByAddresses } from "hooks/useENS";
13+
import { useGetTransferFractionTokens } from "hooks/useFractionToken";
14+
import { type FC, useMemo, useState } from "react";
15+
import { ipfs2https } from "utils/ipfs";
16+
import { abbreviateAddress } from "utils/wallet";
17+
import { UserIcon } from "../icon/UserIcon";
18+
19+
interface Props {
20+
treeId: string;
21+
limit?: number;
22+
}
23+
24+
interface FriendshipData {
25+
user1: string;
26+
user2: string;
27+
totalAmount: number;
28+
transactionCount: number;
29+
}
30+
31+
interface FriendshipItemProps {
32+
treeId: string;
33+
friendship: FriendshipData;
34+
rank: number;
35+
sortBy: SortType;
36+
}
37+
38+
const FriendshipItem: FC<FriendshipItemProps> = ({
39+
treeId,
40+
friendship,
41+
rank,
42+
sortBy,
43+
}) => {
44+
const addresses = useMemo(() => {
45+
return [friendship.user1, friendship.user2];
46+
}, [friendship.user1, friendship.user2]);
47+
48+
const getRankColors = (rank: number) => {
49+
switch (rank) {
50+
case 1:
51+
return {
52+
bg: "yellow.400",
53+
color: "white",
54+
cardBg: "yellow.200",
55+
borderColor: "yellow.300",
56+
statsColor: "yellow.700",
57+
statsFontSize: "xl",
58+
}; // Gold
59+
case 2:
60+
return {
61+
bg: "gray.400",
62+
color: "white",
63+
cardBg: "gray.200",
64+
borderColor: "gray.300",
65+
statsColor: "gray.700",
66+
statsFontSize: "lg",
67+
}; // Silver
68+
case 3:
69+
return {
70+
bg: "orange.600",
71+
color: "white",
72+
cardBg: "orange.200",
73+
borderColor: "orange.300",
74+
statsColor: "orange.700",
75+
statsFontSize: "md",
76+
}; // Bronze
77+
default:
78+
return {
79+
bg: "purple.500",
80+
color: "white",
81+
cardBg: "purple.50",
82+
borderColor: "purple.200",
83+
statsColor: "purple.600",
84+
statsFontSize: "md",
85+
}; // Default purple
86+
}
87+
};
88+
89+
const rankColors = getRankColors(rank);
90+
91+
const { names } = useNamesByAddresses(addresses);
92+
93+
const user1Name = useMemo(() => {
94+
return names?.[0]?.[0];
95+
}, [names]);
96+
97+
const user2Name = useMemo(() => {
98+
return names?.[1]?.[0];
99+
}, [names]);
100+
101+
return (
102+
<Box
103+
h="70px"
104+
py={3}
105+
px={4}
106+
w="full"
107+
borderColor={rankColors.borderColor}
108+
position="relative"
109+
bgColor={rankColors.cardBg}
110+
borderRadius={8}
111+
overflow="hidden"
112+
borderBottomColor={rankColors.borderColor}
113+
>
114+
<Grid
115+
gridTemplateColumns="60px 1fr 100px"
116+
justifyContent="space-between"
117+
alignItems="center"
118+
height="100%"
119+
>
120+
{/* Rank */}
121+
<Flex
122+
alignItems="center"
123+
justifyContent="center"
124+
w="40px"
125+
h="40px"
126+
borderRadius="full"
127+
bgColor={rankColors.bg}
128+
color={rankColors.color}
129+
fontWeight="bold"
130+
fontSize="lg"
131+
>
132+
{rank}
133+
</Flex>
134+
135+
{/* Users */}
136+
<Flex
137+
alignItems="center"
138+
justifyContent="center"
139+
gap={3}
140+
px={2}
141+
minW="0"
142+
>
143+
<Link to={`/${treeId}/member/${friendship.user1}`}>
144+
<VStack gap={1} minW="0" alignItems="center">
145+
<UserIcon
146+
size="32px"
147+
userImageUrl={ipfs2https(user1Name?.text_records?.avatar)}
148+
/>
149+
<Text
150+
fontSize="xs"
151+
fontWeight="medium"
152+
color="gray.700"
153+
maxW="70px"
154+
textAlign="center"
155+
overflow="hidden"
156+
whiteSpace="nowrap"
157+
textOverflow="ellipsis"
158+
>
159+
{user1Name?.name || abbreviateAddress(friendship.user1)}
160+
</Text>
161+
</VStack>
162+
</Link>
163+
164+
<Text
165+
color="purple.500"
166+
fontWeight="bold"
167+
fontSize="lg"
168+
alignSelf="flex-start"
169+
mt={2}
170+
>
171+
172+
</Text>
173+
174+
<Link to={`/${treeId}/member/${friendship.user2}`}>
175+
<VStack gap={1} minW="0" alignItems="center">
176+
<UserIcon
177+
size="32px"
178+
userImageUrl={ipfs2https(user2Name?.text_records?.avatar)}
179+
/>
180+
<Text
181+
fontSize="xs"
182+
fontWeight="medium"
183+
color="gray.700"
184+
maxW="70px"
185+
textAlign="center"
186+
overflow="hidden"
187+
whiteSpace="nowrap"
188+
textOverflow="ellipsis"
189+
>
190+
{user2Name?.name || abbreviateAddress(friendship.user2)}
191+
</Text>
192+
</VStack>
193+
</Link>
194+
</Flex>
195+
196+
{/* Stats */}
197+
<Box textAlign="center" w="100px">
198+
{sortBy === "totalAmount" ? (
199+
<>
200+
<Text
201+
fontSize={rankColors.statsFontSize}
202+
fontWeight="bold"
203+
color={rankColors.statsColor}
204+
>
205+
{friendship.totalAmount}
206+
</Text>
207+
<Text fontSize="xs" color="gray.500">
208+
{friendship.transactionCount}
209+
</Text>
210+
</>
211+
) : (
212+
<>
213+
<Text
214+
fontSize={rankColors.statsFontSize}
215+
fontWeight="bold"
216+
color={rankColors.statsColor}
217+
>
218+
{friendship.transactionCount}
219+
</Text>
220+
<Text fontSize="xs" color="gray.500">
221+
{friendship.totalAmount}pt
222+
</Text>
223+
</>
224+
)}
225+
</Box>
226+
</Grid>
227+
</Box>
228+
);
229+
};
230+
231+
type SortType = "totalAmount" | "transactionCount";
232+
233+
/**
234+
* フレンドシップランキングを表示するコンポーネント
235+
* 二人の間でのアシストクレジット総量と取引回数を表示
236+
*/
237+
export const FriendshipRanking: FC<Props> = ({ treeId, limit = 500 }) => {
238+
const [sortBy, setSortBy] = useState<SortType>("totalAmount");
239+
const { data } = useGetTransferFractionTokens({
240+
where: {
241+
workspaceId: treeId,
242+
},
243+
orderBy: TransferFractionToken_OrderBy.BlockTimestamp,
244+
orderDirection: OrderDirection.Desc,
245+
first: limit,
246+
});
247+
248+
const friendshipData = useMemo(() => {
249+
if (!data?.transferFractionTokens) return [];
250+
251+
// ユーザーペア間のデータを集計
252+
const pairMap = new Map<string, FriendshipData>();
253+
254+
for (const token of data.transferFractionTokens) {
255+
const user1 = token.from.toLowerCase();
256+
const user2 = token.to.toLowerCase();
257+
258+
// アドレスをソートしてペアキーを作成(順序に関係なく同じペアとして扱う)
259+
const sortedPair = [user1, user2].sort();
260+
const pairKey = `${sortedPair[0]}-${sortedPair[1]}`;
261+
262+
if (pairMap.has(pairKey)) {
263+
const existing = pairMap.get(pairKey);
264+
if (existing) {
265+
existing.totalAmount += Number(token.amount);
266+
existing.transactionCount += 1;
267+
}
268+
} else {
269+
pairMap.set(pairKey, {
270+
user1: sortedPair[0],
271+
user2: sortedPair[1],
272+
totalAmount: Number(token.amount),
273+
transactionCount: 1,
274+
});
275+
}
276+
}
277+
278+
// ソート
279+
return Array.from(pairMap.values())
280+
.sort((a, b) => {
281+
if (sortBy === "totalAmount") {
282+
return b.totalAmount - a.totalAmount;
283+
}
284+
return b.transactionCount - a.transactionCount;
285+
})
286+
.slice(0, 20); // 上位20ペアまで表示
287+
}, [data, sortBy]);
288+
289+
if (
290+
!data?.transferFractionTokens ||
291+
data.transferFractionTokens.length === 0
292+
) {
293+
return (
294+
<Box p={8} textAlign="center" color="gray.500">
295+
<Text>フレンドシップデータがありません</Text>
296+
</Box>
297+
);
298+
}
299+
300+
return (
301+
<VStack gap={3}>
302+
<Box w="full" mb={2}>
303+
<HStack justifyContent="center" gap={2}>
304+
<Button
305+
size="sm"
306+
variant={sortBy === "totalAmount" ? "solid" : "outline"}
307+
bgColor={sortBy === "totalAmount" ? "blue.400" : "transparent"}
308+
onClick={() => setSortBy("totalAmount")}
309+
>
310+
総交換量順
311+
</Button>
312+
<Button
313+
size="sm"
314+
variant={sortBy === "transactionCount" ? "solid" : "outline"}
315+
bgColor={sortBy === "transactionCount" ? "blue.400" : "transparent"}
316+
onClick={() => setSortBy("transactionCount")}
317+
>
318+
取引回数順
319+
</Button>
320+
</HStack>
321+
</Box>
322+
{friendshipData.map((friendship, index) => (
323+
<FriendshipItem
324+
treeId={treeId}
325+
key={`friendship_${friendship.user1}_${friendship.user2}`}
326+
friendship={friendship}
327+
rank={index + 1}
328+
sortBy={sortBy}
329+
/>
330+
))}
331+
</VStack>
332+
);
333+
};

pkgs/frontend/app/components/assistcredit/VerticalBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const VerticalBar = ({ treeId }: { treeId: string }) => {
3131
},
3232
orderBy: TransferFractionToken_OrderBy.BlockTimestamp,
3333
orderDirection: OrderDirection.Asc,
34-
first: 100,
34+
first: 500,
3535
});
3636

3737
const { labels, amounts } = useMemo(() => {

pkgs/frontend/app/routes/$treeId_.assistcredit.history.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Box, Heading, Tabs, VStack } from "@chakra-ui/react";
22
import { useParams } from "@remix-run/react";
33
import type { FC } from "react";
44
import { PageHeader } from "~/components/PageHeader";
5+
import { FriendshipRanking } from "~/components/assistcredit/FriendshipRanking";
56
import { AssistCreditHistory } from "~/components/assistcredit/History";
67
import { Treemap } from "~/components/assistcredit/Treemap";
78
import { TreemapReceived } from "~/components/assistcredit/TreemapReceived";
@@ -22,11 +23,17 @@ const WorkspaceMember: FC = () => {
2223
<Tabs.Root defaultValue="list" mt={5}>
2324
<Tabs.List>
2425
<Tabs.Trigger value="list">リスト</Tabs.Trigger>
26+
<Tabs.Trigger value="friendship">フレンドシップ</Tabs.Trigger>
2527
<Tabs.Trigger value="chart">グラフ</Tabs.Trigger>
2628
</Tabs.List>
2729
<Tabs.Content value="list">
2830
<Box mt={2}>
29-
{treeId && <AssistCreditHistory treeId={treeId} limit={100} />}
31+
{treeId && <AssistCreditHistory treeId={treeId} limit={500} />}
32+
</Box>
33+
</Tabs.Content>
34+
<Tabs.Content value="friendship">
35+
<Box mt={2}>
36+
{treeId && <FriendshipRanking treeId={treeId} limit={500} />}
3037
</Box>
3138
</Tabs.Content>
3239
<Tabs.Content value="chart">

0 commit comments

Comments
 (0)