Skip to content

Issue/317: Add friendship ranking tab to assist credit history #379

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
337 changes: 337 additions & 0 deletions pkgs/frontend/app/components/assistcredit/FriendshipRanking.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
import {
Box,
Button,
Flex,
Grid,
HStack,
Text,
VStack,
} from "@chakra-ui/react";
import { Link } from "@remix-run/react";
import { OrderDirection, TransferFractionToken_OrderBy } from "gql/graphql";
import { useNamesByAddresses } from "hooks/useENS";
import { useGetTransferFractionTokens } from "hooks/useFractionToken";
import { type FC, useMemo, useState } from "react";
import { ipfs2https } from "utils/ipfs";
import { abbreviateAddress } from "utils/wallet";
import { UserIcon } from "../icon/UserIcon";

interface Props {
treeId: string;
limit?: number;
}

interface FriendshipData {
user1: string;
user2: string;
totalAmount: number;
transactionCount: number;
}

interface FriendshipItemProps {
treeId: string;
friendship: FriendshipData;
rank: number;
sortBy: SortType;
}

const FriendshipItem: FC<FriendshipItemProps> = ({
treeId,
friendship,
rank,
sortBy,
}) => {
const addresses = useMemo(() => {
return [friendship.user1, friendship.user2];
}, [friendship.user1, friendship.user2]);

const getRankColors = (rank: number) => {
switch (rank) {
case 1:
return {
bg: "yellow.400",
color: "white",
cardBg: "yellow.200",
borderColor: "yellow.300",
statsColor: "yellow.700",
statsFontSize: "xl",
}; // Gold
case 2:
return {
bg: "gray.400",
color: "white",
cardBg: "gray.200",
borderColor: "gray.300",
statsColor: "gray.700",
statsFontSize: "lg",
}; // Silver
case 3:
return {
bg: "orange.600",
color: "white",
cardBg: "orange.200",
borderColor: "orange.300",
statsColor: "orange.700",
statsFontSize: "md",
}; // Bronze
default:
return {
bg: "purple.500",
color: "white",
cardBg: "purple.50",
borderColor: "purple.200",
statsColor: "purple.600",
statsFontSize: "md",
}; // Default purple
}
};

const rankColors = getRankColors(rank);

const { names } = useNamesByAddresses(addresses);

const user1Name = useMemo(() => {
return names?.[0]?.[0];
}, [names]);

const user2Name = useMemo(() => {
return names?.[1]?.[0];
}, [names]);

return (
<Box
h="70px"
py={3}
px={4}
w="full"
borderColor={rankColors.borderColor}
position="relative"
bgColor={rankColors.cardBg}
borderRadius={8}
overflow="hidden"
border="1px solid"
borderBottomColor={rankColors.borderColor}
>
<Grid
gridTemplateColumns="60px 1fr 100px"
justifyContent="space-between"
alignItems="center"
height="100%"
>
{/* Rank */}
<Flex
alignItems="center"
justifyContent="center"
w="40px"
h="40px"
borderRadius="full"
bgColor={rankColors.bg}
color={rankColors.color}
fontWeight="bold"
fontSize="lg"
>
{rank}
</Flex>

{/* Users */}
<Flex
alignItems="center"
justifyContent="center"
gap={3}
px={2}
minW="0"
>
<Link to={`/${treeId}/member/${friendship.user1}`}>
<VStack gap={1} minW="0" alignItems="center">
<UserIcon
size="32px"
userImageUrl={ipfs2https(user1Name?.text_records?.avatar)}
/>
<Text
fontSize="xs"
fontWeight="medium"
color="gray.700"
maxW="70px"
textAlign="center"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
>
{user1Name?.name || abbreviateAddress(friendship.user1)}
</Text>
</VStack>
</Link>

<Text
color="purple.500"
fontWeight="bold"
fontSize="lg"
alignSelf="flex-start"
mt={2}
>
</Text>

<Link to={`/${treeId}/member/${friendship.user2}`}>
<VStack gap={1} minW="0" alignItems="center">
<UserIcon
size="32px"
userImageUrl={ipfs2https(user2Name?.text_records?.avatar)}
/>
<Text
fontSize="xs"
fontWeight="medium"
color="gray.700"
maxW="70px"
textAlign="center"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
>
{user2Name?.name || abbreviateAddress(friendship.user2)}
</Text>
</VStack>
</Link>
</Flex>

{/* Stats */}
<Box textAlign="center" w="100px">
{sortBy === "totalAmount" ? (
<>
<Text
fontSize={rankColors.statsFontSize}
fontWeight="bold"
color={rankColors.statsColor}
>
{friendship.totalAmount}
</Text>
<Text fontSize="xs" color="gray.500">
{friendship.transactionCount}回
</Text>
</>
) : (
<>
<Text
fontSize={rankColors.statsFontSize}
fontWeight="bold"
color={rankColors.statsColor}
>
{friendship.transactionCount}回
</Text>
<Text fontSize="xs" color="gray.500">
{friendship.totalAmount}pt
</Text>
</>
)}
</Box>
</Grid>
</Box>
);
};

type SortType = "totalAmount" | "transactionCount";

/**
* フレンドシップランキングを表示するコンポーネント
* 二人の間でのアシストクレジット総量と取引回数を表示
*/
export const FriendshipRanking: FC<Props> = ({ treeId, limit = 50 }) => {
const [sortBy, setSortBy] = useState<SortType>("totalAmount");
const { data } = useGetTransferFractionTokens({
where: {
workspaceId: treeId,
},
orderBy: TransferFractionToken_OrderBy.BlockTimestamp,
orderDirection: OrderDirection.Desc,
first: limit,
});

const friendshipData = useMemo(() => {
if (!data?.transferFractionTokens) return [];

// ユーザーペア間のデータを集計
const pairMap = new Map<string, FriendshipData>();

for (const token of data.transferFractionTokens) {
const user1 = token.from.toLowerCase();
const user2 = token.to.toLowerCase();

// アドレスをソートしてペアキーを作成(順序に関係なく同じペアとして扱う)
const sortedPair = [user1, user2].sort();
const pairKey = `${sortedPair[0]}-${sortedPair[1]}`;

if (pairMap.has(pairKey)) {
const existing = pairMap.get(pairKey);
if (existing) {
existing.totalAmount += Number(token.amount);
existing.transactionCount += 1;
}
} else {
pairMap.set(pairKey, {
user1: sortedPair[0],
user2: sortedPair[1],
totalAmount: Number(token.amount),
transactionCount: 1,
});
}
}

// ソート
return Array.from(pairMap.values())
.sort((a, b) => {
if (sortBy === "totalAmount") {
return b.totalAmount - a.totalAmount;
}
return b.transactionCount - a.transactionCount;
})
.slice(0, 20); // 上位20ペアまで表示
}, [data, sortBy]);

if (
!data?.transferFractionTokens ||
data.transferFractionTokens.length === 0
) {
return (
<Box p={8} textAlign="center" color="gray.500">
<Text>フレンドシップデータがありません</Text>
</Box>
);
}

return (
<VStack gap={3} mt={4}>
<Box w="full" mb={2}>
<Text fontSize="sm" color="gray.600" textAlign="center">
コミュニティ内での友情ランキング
</Text>
<HStack justifyContent="center" mt={3} gap={2}>
<Button
size="sm"
variant={sortBy === "totalAmount" ? "solid" : "outline"}
colorScheme="purple"
onClick={() => setSortBy("totalAmount")}
>
総交換量順
</Button>
<Button
size="sm"
variant={sortBy === "transactionCount" ? "solid" : "outline"}
colorScheme="purple"
onClick={() => setSortBy("transactionCount")}
>
取引回数順
</Button>
</HStack>
</Box>
{friendshipData.map((friendship, index) => (
<FriendshipItem
treeId={treeId}
key={`friendship_${friendship.user1}_${friendship.user2}`}
friendship={friendship}
rank={index + 1}
sortBy={sortBy}
/>
))}
</VStack>
);
};
7 changes: 7 additions & 0 deletions pkgs/frontend/app/routes/$treeId_.assistcredit.history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Box, Heading, Tabs, VStack } from "@chakra-ui/react";
import { useParams } from "@remix-run/react";
import type { FC } from "react";
import { PageHeader } from "~/components/PageHeader";
import { FriendshipRanking } from "~/components/assistcredit/FriendshipRanking";
import { AssistCreditHistory } from "~/components/assistcredit/History";
import { Treemap } from "~/components/assistcredit/Treemap";
import { TreemapReceived } from "~/components/assistcredit/TreemapReceived";
Expand All @@ -22,13 +23,19 @@ const WorkspaceMember: FC = () => {
<Tabs.Root defaultValue="list" mt={5}>
<Tabs.List>
<Tabs.Trigger value="list">リスト</Tabs.Trigger>
<Tabs.Trigger value="friendship">フレンドシップ</Tabs.Trigger>
<Tabs.Trigger value="chart">グラフ</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="list">
<Box mt={2}>
{treeId && <AssistCreditHistory treeId={treeId} limit={100} />}
</Box>
</Tabs.Content>
<Tabs.Content value="friendship">
<Box mt={2}>
{treeId && <FriendshipRanking treeId={treeId} limit={200} />}
</Box>
</Tabs.Content>
<Tabs.Content value="chart">
{treeId && (
<VStack gap={6} alignItems="stretch" width="100%">
Expand Down
Loading