Skip to content

Commit 5ef8ff0

Browse files
committed
feat(users): implement question statistics and points history
1 parent 0883829 commit 5ef8ff0

File tree

6 files changed

+242
-17
lines changed

6 files changed

+242
-17
lines changed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"use client";
2+
3+
import { CardLayout } from "@/components/card-layout";
4+
import { type FragmentType, graphql, useFragment } from "@/gql";
5+
import { Trophy } from "lucide-react";
6+
7+
const USER_POINTS_CARD_FRAGMENT = graphql(`
8+
fragment UserPointsCard on User {
9+
totalPoints
10+
11+
points(first: 5, orderBy: { field: GRANTED_AT, direction: DESC }) {
12+
edges {
13+
node {
14+
id
15+
...UserPointHistoryLine
16+
}
17+
}
18+
}
19+
}
20+
`);
21+
22+
const USER_POINT_HISTORY_LINE_FRAGMENT = graphql(`
23+
fragment UserPointHistoryLine on Point {
24+
points
25+
description
26+
grantedAt
27+
}
28+
`);
29+
30+
export function PointsCard({
31+
fragment,
32+
}: {
33+
fragment: FragmentType<typeof USER_POINTS_CARD_FRAGMENT>;
34+
}) {
35+
const { totalPoints, points } = useFragment(
36+
USER_POINTS_CARD_FRAGMENT,
37+
fragment,
38+
);
39+
40+
return (
41+
<CardLayout title="總積分" description="這個使用者的總積分與最近積分紀錄。">
42+
<div className="space-y-4">
43+
<div className="flex items-center gap-3">
44+
<Trophy className="h-8 w-8 text-yellow-500" />
45+
<div>
46+
<p className="text-3xl font-bold">{totalPoints}</p>
47+
<p className="text-sm text-muted-foreground">積分</p>
48+
</div>
49+
</div>
50+
51+
{points?.edges && points.edges.length > 0 && (
52+
<div className="border-t pt-4">
53+
<p className="mb-2 text-sm font-medium">最近積分紀錄</p>
54+
<div className="space-y-2">
55+
{points.edges
56+
.map((edge) => {
57+
if (!edge?.node) return null;
58+
return <PointHistoryLine key={edge.node.id} fragment={edge.node} />;
59+
})}
60+
</div>
61+
</div>
62+
)}
63+
</div>
64+
</CardLayout>
65+
);
66+
}
67+
68+
function PointHistoryLine({
69+
fragment,
70+
}: {
71+
fragment: FragmentType<typeof USER_POINT_HISTORY_LINE_FRAGMENT>;
72+
}) {
73+
const { points, description, grantedAt } = useFragment(
74+
USER_POINT_HISTORY_LINE_FRAGMENT,
75+
fragment,
76+
);
77+
78+
return (
79+
<div className={`flex items-start justify-between gap-2 text-sm`}>
80+
<div className="flex-1">
81+
<p className="font-medium">{description || "積分取得"}</p>
82+
<p className="text-xs text-muted-foreground">
83+
{new Date(grantedAt).toLocaleString("zh-TW", {
84+
timeZone: "Asia/Taipei",
85+
})}
86+
</p>
87+
</div>
88+
<Point point={points} />
89+
</div>
90+
);
91+
}
92+
93+
function Point({ point }: { point: number }) {
94+
const pointAbs = Math.abs(point);
95+
96+
if (point > 0) {
97+
return <span className="font-bold text-green-600">+{pointAbs}</span>;
98+
}
99+
100+
if (point < 0) {
101+
return <span className="font-bold text-red-600">-{pointAbs}</span>;
102+
}
103+
104+
return <span className="text-muted-foreground">{pointAbs}</span>;
105+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"use client";
2+
3+
import { CardLayout } from "@/components/card-layout";
4+
import { type FragmentType, graphql, useFragment } from "@/gql";
5+
import { DIFFICULTY_TRANSLATION } from "@/lib/translation";
6+
import { BookOpen, CheckCircle2, FileQuestion } from "lucide-react";
7+
8+
const USER_QUESTIONS_CARD_FRAGMENT = graphql(`
9+
fragment UserQuestionsCard on User {
10+
submissionStatistics {
11+
totalQuestions
12+
solvedQuestions
13+
attemptedQuestions
14+
15+
solvedQuestionByDifficulty {
16+
difficulty
17+
solvedQuestions
18+
}
19+
}
20+
}
21+
`);
22+
23+
export function QuestionsCard({ fragment }: { fragment: FragmentType<typeof USER_QUESTIONS_CARD_FRAGMENT> }) {
24+
const { submissionStatistics } = useFragment(USER_QUESTIONS_CARD_FRAGMENT, fragment);
25+
26+
if (!submissionStatistics) {
27+
return (
28+
<CardLayout title="做題統計" description="這個使用者的做題統計資訊。">
29+
<p className="text-sm text-muted-foreground">暫無資料</p>
30+
</CardLayout>
31+
);
32+
}
33+
34+
const { totalQuestions, solvedQuestions, attemptedQuestions, solvedQuestionByDifficulty } = submissionStatistics;
35+
36+
return (
37+
<CardLayout title="做題統計" description="這個使用者的做題統計資訊。">
38+
<div className="space-y-4">
39+
<div className="grid grid-cols-3 gap-4">
40+
<div className="flex flex-col gap-1">
41+
<div
42+
className={`flex items-center gap-2 text-sm text-muted-foreground`}
43+
>
44+
<FileQuestion className="h-4 w-4" />
45+
<span>總題數</span>
46+
</div>
47+
<p className="text-2xl font-bold">{totalQuestions}</p>
48+
</div>
49+
<div className="flex flex-col gap-1">
50+
<div
51+
className={`flex items-center gap-2 text-sm text-muted-foreground`}
52+
>
53+
<BookOpen className="h-4 w-4" />
54+
<span>嘗試題數</span>
55+
</div>
56+
<p className="text-2xl font-bold">{attemptedQuestions}</p>
57+
</div>
58+
<div className="flex flex-col gap-1">
59+
<div
60+
className={`flex items-center gap-2 text-sm text-muted-foreground`}
61+
>
62+
<CheckCircle2 className="h-4 w-4" />
63+
<span>完成題數</span>
64+
</div>
65+
<p className="text-2xl font-bold">{solvedQuestions}</p>
66+
</div>
67+
</div>
68+
69+
{solvedQuestionByDifficulty && solvedQuestionByDifficulty.length > 0 && (
70+
<div className="border-t pt-4">
71+
<p className="mb-2 text-sm font-medium">各難度完成題數</p>
72+
<div className="space-y-2">
73+
{solvedQuestionByDifficulty.map(({ difficulty, solvedQuestions }) => (
74+
<div
75+
key={difficulty}
76+
className={`flex items-center justify-between`}
77+
>
78+
<span className="text-sm">{DIFFICULTY_TRANSLATION[difficulty]}</span>
79+
<span className="font-medium">{solvedQuestions}</span>
80+
</div>
81+
))}
82+
</div>
83+
</div>
84+
)}
85+
</div>
86+
</CardLayout>
87+
);
88+
}

app/(admin)/(user-management)/users/[id]/_components/user-cards.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ import { graphql } from "@/gql";
44
import { useSuspenseQuery } from "@apollo/client/react";
55
import { AuditInfoCard } from "./audit-info";
66
import { GroupsCard } from "./groups";
7+
import { PointsCard } from "./points";
8+
import { QuestionsCard } from "./questions";
79

810
const USER_CARDS_QUERY = graphql(`
911
query UserCards($id: ID!) {
1012
user(id: $id) {
1113
id
1214
...UserGroupsCard
1315
...UserAuditInfoCard
16+
...UserPointsCard
17+
...UserQuestionsCard
1418
}
1519
}
1620
`);
@@ -31,6 +35,8 @@ export function UserCards({ id }: { id: string }) {
3135
>
3236
<GroupsCard fragment={fragment} />
3337
<AuditInfoCard fragment={fragment} />
38+
<PointsCard fragment={fragment} />
39+
<QuestionsCard fragment={fragment} />
3440
</div>
3541
);
3642
}

gql/gql.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ type Documents = {
2121
"\n fragment PointDetailsCard on Point {\n points\n description\n grantedAt\n }\n": typeof types.PointDetailsCardFragmentDoc,
2222
"\n fragment PointUserCard on Point {\n user {\n id\n name\n }\n }\n": typeof types.PointUserCardFragmentDoc,
2323
"\n mutation CreatePoint($input: CreatePointInput!) {\n createPoint(input: $input) {\n id\n }\n }\n": typeof types.CreatePointDocument,
24-
"\n query CreatePointDialogContent {\n users(first: 100) {\n edges {\n node {\n id\n name\n email\n }\n }\n }\n }\n": typeof types.CreatePointDialogContentDocument,
2524
"\n query PointsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n $where: PointWhereInput\n ) {\n points(\n first: $first\n after: $after\n last: $last\n before: $before\n where: $where\n orderBy: { field: GRANTED_AT, direction: DESC }\n ) {\n edges {\n node {\n id\n ...PointsTableRow\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.PointsTableDocument,
2625
"\n fragment PointsTableRow on Point {\n id\n user {\n id\n name\n }\n points\n description\n grantedAt\n }\n": typeof types.PointsTableRowFragmentDoc,
2726
"\n query UpdatePointsFormUserInfo($id: ID!) {\n user(id: $id) {\n id\n name\n email\n }\n }\n": typeof types.UpdatePointsFormUserInfoDocument,
@@ -80,7 +79,10 @@ type Documents = {
8079
"\n fragment UserAuditInfoCard on User {\n createdAt\n updatedAt\n }\n": typeof types.UserAuditInfoCardFragmentDoc,
8180
"\n fragment UserGroupsCard on User {\n group {\n id\n name\n }\n }\n": typeof types.UserGroupsCardFragmentDoc,
8281
"\n query UserHeader($id: ID!) {\n user(id: $id) {\n id\n name\n email\n avatar\n }\n }\n": typeof types.UserHeaderDocument,
83-
"\n query UserCards($id: ID!) {\n user(id: $id) {\n id\n ...UserGroupsCard\n ...UserAuditInfoCard\n }\n }\n": typeof types.UserCardsDocument,
82+
"\n fragment UserPointsCard on User {\n totalPoints\n\n points(first: 5, orderBy: { field: GRANTED_AT, direction: DESC }) {\n edges {\n node {\n id\n ...UserPointHistoryLine\n }\n }\n }\n }\n": typeof types.UserPointsCardFragmentDoc,
83+
"\n fragment UserPointHistoryLine on Point {\n points\n description\n grantedAt\n }\n": typeof types.UserPointHistoryLineFragmentDoc,
84+
"\n fragment UserQuestionsCard on User {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n attemptedQuestions\n\n solvedQuestionByDifficulty {\n difficulty\n solvedQuestions\n }\n }\n }\n": typeof types.UserQuestionsCardFragmentDoc,
85+
"\n query UserCards($id: ID!) {\n user(id: $id) {\n id\n ...UserGroupsCard\n ...UserAuditInfoCard\n ...UserPointsCard\n ...UserQuestionsCard\n }\n }\n": typeof types.UserCardsDocument,
8486
"\n mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {\n updateUser(id: $id, input: $input) {\n id\n }\n }\n": typeof types.UpdateUserDocument,
8587
"\n mutation DeleteUser($id: ID!) {\n deleteUser(id: $id)\n }\n": typeof types.DeleteUserDocument,
8688
"\n mutation LogoutUserDevices($userID: ID!) {\n logoutUser(userID: $userID)\n }\n": typeof types.LogoutUserDevicesDocument,
@@ -107,7 +109,6 @@ const documents: Documents = {
107109
"\n fragment PointDetailsCard on Point {\n points\n description\n grantedAt\n }\n": types.PointDetailsCardFragmentDoc,
108110
"\n fragment PointUserCard on Point {\n user {\n id\n name\n }\n }\n": types.PointUserCardFragmentDoc,
109111
"\n mutation CreatePoint($input: CreatePointInput!) {\n createPoint(input: $input) {\n id\n }\n }\n": types.CreatePointDocument,
110-
"\n query CreatePointDialogContent {\n users(first: 100) {\n edges {\n node {\n id\n name\n email\n }\n }\n }\n }\n": types.CreatePointDialogContentDocument,
111112
"\n query PointsTable(\n $first: Int\n $after: Cursor\n $last: Int\n $before: Cursor\n $where: PointWhereInput\n ) {\n points(\n first: $first\n after: $after\n last: $last\n before: $before\n where: $where\n orderBy: { field: GRANTED_AT, direction: DESC }\n ) {\n edges {\n node {\n id\n ...PointsTableRow\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.PointsTableDocument,
112113
"\n fragment PointsTableRow on Point {\n id\n user {\n id\n name\n }\n points\n description\n grantedAt\n }\n": types.PointsTableRowFragmentDoc,
113114
"\n query UpdatePointsFormUserInfo($id: ID!) {\n user(id: $id) {\n id\n name\n email\n }\n }\n": types.UpdatePointsFormUserInfoDocument,
@@ -166,7 +167,10 @@ const documents: Documents = {
166167
"\n fragment UserAuditInfoCard on User {\n createdAt\n updatedAt\n }\n": types.UserAuditInfoCardFragmentDoc,
167168
"\n fragment UserGroupsCard on User {\n group {\n id\n name\n }\n }\n": types.UserGroupsCardFragmentDoc,
168169
"\n query UserHeader($id: ID!) {\n user(id: $id) {\n id\n name\n email\n avatar\n }\n }\n": types.UserHeaderDocument,
169-
"\n query UserCards($id: ID!) {\n user(id: $id) {\n id\n ...UserGroupsCard\n ...UserAuditInfoCard\n }\n }\n": types.UserCardsDocument,
170+
"\n fragment UserPointsCard on User {\n totalPoints\n\n points(first: 5, orderBy: { field: GRANTED_AT, direction: DESC }) {\n edges {\n node {\n id\n ...UserPointHistoryLine\n }\n }\n }\n }\n": types.UserPointsCardFragmentDoc,
171+
"\n fragment UserPointHistoryLine on Point {\n points\n description\n grantedAt\n }\n": types.UserPointHistoryLineFragmentDoc,
172+
"\n fragment UserQuestionsCard on User {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n attemptedQuestions\n\n solvedQuestionByDifficulty {\n difficulty\n solvedQuestions\n }\n }\n }\n": types.UserQuestionsCardFragmentDoc,
173+
"\n query UserCards($id: ID!) {\n user(id: $id) {\n id\n ...UserGroupsCard\n ...UserAuditInfoCard\n ...UserPointsCard\n ...UserQuestionsCard\n }\n }\n": types.UserCardsDocument,
170174
"\n mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {\n updateUser(id: $id, input: $input) {\n id\n }\n }\n": types.UpdateUserDocument,
171175
"\n mutation DeleteUser($id: ID!) {\n deleteUser(id: $id)\n }\n": types.DeleteUserDocument,
172176
"\n mutation LogoutUserDevices($userID: ID!) {\n logoutUser(userID: $userID)\n }\n": types.LogoutUserDevicesDocument,
@@ -228,10 +232,6 @@ export function graphql(source: "\n fragment PointUserCard on Point {\n user
228232
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
229233
*/
230234
export function graphql(source: "\n mutation CreatePoint($input: CreatePointInput!) {\n createPoint(input: $input) {\n id\n }\n }\n"): (typeof documents)["\n mutation CreatePoint($input: CreatePointInput!) {\n createPoint(input: $input) {\n id\n }\n }\n"];
231-
/**
232-
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
233-
*/
234-
export function graphql(source: "\n query CreatePointDialogContent {\n users(first: 100) {\n edges {\n node {\n id\n name\n email\n }\n }\n }\n }\n"): (typeof documents)["\n query CreatePointDialogContent {\n users(first: 100) {\n edges {\n node {\n id\n name\n email\n }\n }\n }\n }\n"];
235235
/**
236236
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
237237
*/
@@ -467,7 +467,19 @@ export function graphql(source: "\n query UserHeader($id: ID!) {\n user(id:
467467
/**
468468
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
469469
*/
470-
export function graphql(source: "\n query UserCards($id: ID!) {\n user(id: $id) {\n id\n ...UserGroupsCard\n ...UserAuditInfoCard\n }\n }\n"): (typeof documents)["\n query UserCards($id: ID!) {\n user(id: $id) {\n id\n ...UserGroupsCard\n ...UserAuditInfoCard\n }\n }\n"];
470+
export function graphql(source: "\n fragment UserPointsCard on User {\n totalPoints\n\n points(first: 5, orderBy: { field: GRANTED_AT, direction: DESC }) {\n edges {\n node {\n id\n ...UserPointHistoryLine\n }\n }\n }\n }\n"): (typeof documents)["\n fragment UserPointsCard on User {\n totalPoints\n\n points(first: 5, orderBy: { field: GRANTED_AT, direction: DESC }) {\n edges {\n node {\n id\n ...UserPointHistoryLine\n }\n }\n }\n }\n"];
471+
/**
472+
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
473+
*/
474+
export function graphql(source: "\n fragment UserPointHistoryLine on Point {\n points\n description\n grantedAt\n }\n"): (typeof documents)["\n fragment UserPointHistoryLine on Point {\n points\n description\n grantedAt\n }\n"];
475+
/**
476+
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
477+
*/
478+
export function graphql(source: "\n fragment UserQuestionsCard on User {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n attemptedQuestions\n\n solvedQuestionByDifficulty {\n difficulty\n solvedQuestions\n }\n }\n }\n"): (typeof documents)["\n fragment UserQuestionsCard on User {\n submissionStatistics {\n totalQuestions\n solvedQuestions\n attemptedQuestions\n\n solvedQuestionByDifficulty {\n difficulty\n solvedQuestions\n }\n }\n }\n"];
479+
/**
480+
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
481+
*/
482+
export function graphql(source: "\n query UserCards($id: ID!) {\n user(id: $id) {\n id\n ...UserGroupsCard\n ...UserAuditInfoCard\n ...UserPointsCard\n ...UserQuestionsCard\n }\n }\n"): (typeof documents)["\n query UserCards($id: ID!) {\n user(id: $id) {\n id\n ...UserGroupsCard\n ...UserAuditInfoCard\n ...UserPointsCard\n ...UserQuestionsCard\n }\n }\n"];
471483
/**
472484
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
473485
*/

0 commit comments

Comments
 (0)