Skip to content

Commit 4b6558d

Browse files
authored
Merge pull request #17 from cp-20/feat/yearly-recap
年ごとの recap ページ
2 parents e5df649 + 3f334bc commit 4b6558d

38 files changed

+1982
-189
lines changed

apps/client/package.json

+10
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@
1919
"@traq-ing/server": "workspace:*",
2020
"chart.js": "^4.4.7",
2121
"clsx": "^2.1.1",
22+
"d3-cloud": "^1.2.7",
23+
"d3-scale": "^4.0.2",
24+
"d3-scale-chromatic": "^3.1.0",
25+
"d3-selection": "^3.0.0",
2226
"dayjs": "^1.11.13",
2327
"deepmerge": "^4.3.1",
2428
"hono": "^4.6.14",
2529
"jotai": "^2.10.4",
2630
"react": "^18.3.1",
2731
"react-chartjs-2": "^5.2.0",
32+
"react-countup": "^6.5.3",
33+
"react-d3-cloud": "^1.0.6",
2834
"react-dom": "^18.3.1",
2935
"react-router": "^7.0.2",
3036
"swr": "^2.2.5",
@@ -33,6 +39,10 @@
3339
},
3440
"devDependencies": {
3541
"@react-router/dev": "^7.0.2",
42+
"@types/d3-cloud": "^1.2.9",
43+
"@types/d3-scale": "^4.0.8",
44+
"@types/d3-scale-chromatic": "^3.1.0",
45+
"@types/d3-selection": "^3.0.11",
3646
"@types/react": "^18.3.18",
3747
"@types/react-dom": "^18.3.5",
3848
"@vitejs/plugin-react-swc": "^3.7.2",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { IconCrown } from '@tabler/icons-react';
2+
import type { FC } from 'react';
3+
4+
export const rankColors = ['orange', 'silver', 'indianred'];
5+
6+
export const RankingIcon: FC<{ rank: number; size?: number }> = ({ rank, size }) => (
7+
<IconCrown fill={rankColors[rank - 1]} color={rankColors[rank - 1]} size={size} />
8+
);
+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// ref: https://github.com/Yoctol/react-d3-cloud/blob/b7a35e3d6a5db85a616847328c6f798213fb9667/src/WordCloud.tsx
2+
3+
import { useRef, memo, type FC, useEffect } from 'react';
4+
import cloud from 'd3-cloud';
5+
import isDeepEqual from 'react-fast-compare';
6+
import { type BaseType, type ValueFn, select } from 'd3-selection';
7+
import { scaleOrdinal } from 'd3-scale';
8+
import { schemeCategory10 } from 'd3-scale-chromatic';
9+
10+
interface Datum {
11+
text: string;
12+
value: number;
13+
}
14+
15+
export interface Word extends cloud.Word {
16+
text: string;
17+
value: number;
18+
}
19+
20+
type Props = {
21+
data: Datum[];
22+
width?: number;
23+
height?: number;
24+
font?: string | ((word: Word, index: number) => string);
25+
fontStyle?: string | ((word: Word, index: number) => string);
26+
fontWeight?: string | number | ((word: Word, index: number) => string | number);
27+
fontSize?: number | ((word: Word, index: number) => number);
28+
spiral?: 'archimedean' | 'rectangular' | ((size: [number, number]) => (t: number) => [number, number]);
29+
padding?: number | ((word: Word, index: number) => number);
30+
random?: () => number;
31+
fill?: ValueFn<SVGTextElement, Word, string>;
32+
onWordClick?: (this: BaseType, event: unknown, d: Word) => void;
33+
onWordMouseOver?: (this: BaseType, event: unknown, d: Word) => void;
34+
onWordMouseOut?: (this: BaseType, event: unknown, d: Word) => void;
35+
};
36+
37+
const defaultScaleOrdinal = scaleOrdinal(schemeCategory10);
38+
39+
const WordCloud: FC<Props> = ({
40+
data,
41+
width = 700,
42+
height = 600,
43+
font = 'serif',
44+
fontStyle = 'normal',
45+
fontWeight = 'normal',
46+
fontSize = (d) => Math.sqrt(d.value),
47+
// eslint-disable-next-line no-bitwise
48+
spiral = 'archimedean',
49+
padding = 1,
50+
// @ts-ignore The ordinal function should accept number
51+
fill = (_, i) => defaultScaleOrdinal(i),
52+
onWordClick,
53+
onWordMouseOver,
54+
onWordMouseOut,
55+
}) => {
56+
const ref = useRef<HTMLDivElement>(null);
57+
58+
useEffect(() => {
59+
if (!ref.current) return;
60+
61+
// clear old data
62+
select(ref.current).select('svg').remove();
63+
64+
// render based on new data
65+
const layout = cloud<Word>()
66+
.words(data)
67+
.size([width, height])
68+
.font(font)
69+
.fontStyle(fontStyle)
70+
.fontWeight(fontWeight)
71+
.fontSize(fontSize)
72+
.spiral(spiral)
73+
.padding(padding)
74+
.rotate(() => 0)
75+
.random(Math.random)
76+
.on('end', (words) => {
77+
const [w, h] = layout.size();
78+
79+
const texts = select(ref.current)
80+
.append('svg')
81+
.attr('viewBox', `0 0 ${w} ${h}`)
82+
.attr('width', w)
83+
.attr('height', h)
84+
.attr('preserveAspectRatio', 'xMinYMin meet')
85+
.append('g')
86+
.attr('transform', `translate(${w / 2},${h / 2})`)
87+
.selectAll('text')
88+
.data(words)
89+
.enter()
90+
.append('text')
91+
.style('font-family', ((d) => d.font) as ValueFn<SVGTextElement, Word, string>)
92+
.style('font-style', ((d) => d.style) as ValueFn<SVGTextElement, Word, string>)
93+
.style('font-weight', ((d) => d.weight) as ValueFn<SVGTextElement, Word, string | number>)
94+
.style('font-size', ((d) => `${d.size}px`) as ValueFn<SVGTextElement, Word, string>)
95+
.style('fill', fill)
96+
.attr('text-anchor', 'middle')
97+
.attr('transform', (d) => `translate(${[d.x, d.y]})rotate(${d.rotate})`)
98+
.text((d) => d.text);
99+
100+
if (onWordClick) {
101+
texts.on('click', onWordClick);
102+
}
103+
if (onWordMouseOver) {
104+
texts.on('mouseover', onWordMouseOver);
105+
}
106+
if (onWordMouseOut) {
107+
texts.on('mouseout', onWordMouseOut);
108+
}
109+
});
110+
111+
layout.start();
112+
}, [
113+
data,
114+
fill,
115+
font,
116+
fontSize,
117+
fontWeight,
118+
fontStyle,
119+
height,
120+
onWordClick,
121+
onWordMouseOut,
122+
onWordMouseOver,
123+
padding,
124+
spiral,
125+
width,
126+
]);
127+
128+
return <div ref={ref} />;
129+
};
130+
131+
export default memo(WordCloud, isDeepEqual);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useCountUp } from 'react-countup';
2+
import clsx from 'clsx';
3+
import { type ReactNode, type ComponentProps, type FC, useState, useRef, useEffect } from 'react';
4+
import { useIntersection } from '@mantine/hooks';
5+
import { Card } from '@/components/Card';
6+
7+
type Props = {
8+
label: ReactNode;
9+
value: number;
10+
valueProps?: ComponentProps<'span'>;
11+
unit?: ReactNode;
12+
annotation?: ReactNode;
13+
duration?: number;
14+
start: number;
15+
};
16+
17+
export const AnimatedStat: FC<Props> = ({ label, value, valueProps, unit, annotation, duration, start }) => {
18+
const countUpRef = useRef<HTMLSpanElement>(null);
19+
const [started, setStarted] = useState(false);
20+
const [ended, setEnded] = useState(false);
21+
const count = useCountUp({
22+
ref: countUpRef,
23+
start,
24+
end: value,
25+
duration: duration ?? 2,
26+
startOnMount: false,
27+
onEnd: () => setEnded(true),
28+
});
29+
30+
const { ref: containerRef, entry } = useIntersection({
31+
threshold: 0,
32+
rootMargin: '0px 0px -20% 0px',
33+
});
34+
35+
useEffect(() => {
36+
if (entry?.isIntersecting && !started) {
37+
count.start();
38+
setStarted(true);
39+
}
40+
}, [started, entry, count.start]);
41+
42+
return (
43+
<Card ref={containerRef}>
44+
<div>
45+
<div className="text-sm font-bold">{label}</div>
46+
<div>
47+
<span
48+
{...valueProps}
49+
className={clsx('text-4xl text-blue-600 font-medium', valueProps?.className)}
50+
ref={countUpRef}
51+
/>
52+
{unit && <span>{unit}</span>}
53+
</div>
54+
{annotation &&
55+
(ended ? (
56+
<div className="text-sm mt-1 animate-wipe-right">{annotation}</div>
57+
) : (
58+
<div className="text-sm mt-1 h-5" />
59+
))}
60+
</div>
61+
</Card>
62+
);
63+
};

apps/client/src/components/hours/common.ts

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const commonHoursChartOption = mergeOptions(getCommonLineChartOptions(fal
2727

2828
export const commonHoursQuery = {
2929
groupBy: 'hour',
30+
target: 'count',
3031
} satisfies MessagesQuery & StampsQuery;
3132

3233
const diff = new Date().getTimezoneOffset() / 60;

apps/client/src/components/messages/TopReactedMessages.tsx

+7-3
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,31 @@ import { TraqMessage } from '@/components/messages/TraqMessage';
44
import type { StampsQuery } from '@traq-ing/database';
55
import clsx from 'clsx';
66
import { type FC, useMemo } from 'react';
7+
import { dateRangeToQuery, type DateRange } from '@/composables/useDateRangePicker';
78

89
type Props = {
10+
range?: DateRange;
911
stampId: string | null;
1012
channelId?: string;
1113
gaveUserId?: string;
1214
receivedUserId?: string;
15+
limit?: number;
1316
};
1417

15-
export const TopReactedMessages: FC<Props> = ({ stampId, channelId, gaveUserId, receivedUserId }) => {
18+
export const TopReactedMessages: FC<Props> = ({ range, stampId, channelId, gaveUserId, receivedUserId, limit }) => {
1619
const query = useMemo(
1720
() =>
1821
({
22+
...(range && dateRangeToQuery(range)),
1923
channelId,
2024
userId: gaveUserId,
2125
messageUserId: receivedUserId,
2226
stampId: stampId ?? undefined,
2327
groupBy: 'message',
2428
orderBy: 'count',
25-
limit: 10,
29+
limit: limit ?? 10,
2630
}) satisfies StampsQuery,
27-
[stampId, channelId, gaveUserId, receivedUserId],
31+
[range, stampId, channelId, gaveUserId, receivedUserId, limit],
2832
);
2933
const { stamps, loading } = useStamps(query);
3034

apps/client/src/components/rankings/ChannelRanking.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ export const MessagesChannelRanking: FC<MessagesChannelRankingProps> = ({ range,
5858
() =>
5959
({
6060
userId,
61+
target: 'count',
6162
groupBy: 'channel',
62-
orderBy: 'count',
63+
orderBy: 'target',
6364
order: 'desc',
6465
limit: limit ?? 10,
6566
...(range && dateRangeToQuery(range)),
@@ -77,8 +78,9 @@ export const MessagesChannelRankingWithSubscription: FC<MessagesChannelRankingPr
7778
() =>
7879
({
7980
userId,
81+
target: 'count',
8082
groupBy: 'channel',
81-
orderBy: 'count',
83+
orderBy: 'target',
8284
order: 'desc',
8385
limit: limit ?? 10,
8486
...(range && dateRangeToQuery(range)),

apps/client/src/components/rankings/UserRanking.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ export const MessagesUserRanking: FC<MessagesUserRankingProps> = ({ range, chann
5252
() =>
5353
({
5454
channelId,
55+
target: 'count',
5556
groupBy: 'user',
56-
orderBy: 'count',
57+
orderBy: 'target',
5758
order: 'desc',
5859
limit: limit ?? 10,
5960
...(range && dateRangeToQuery(range)),

apps/client/src/components/rankings/channel.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export const ChannelRankingItemWithUsers: FC<ChannelRankingItemWithUsersProps> =
6565
({
6666
channelId,
6767
groupBy: 'user',
68-
orderBy: 'count',
68+
orderBy: 'target',
6969
order: 'desc',
7070
limit: onlyTop ? 1 : undefined,
7171
...(range && dateRangeToQuery(range)),

apps/client/src/components/rankings/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export const RankingItemRank: FC<RankingItemRankProps> = ({ rank }) => {
7878
};
7979

8080
export type RankingItemValueProps = {
81-
value: number;
81+
value: ReactNode;
8282
};
8383

8484
export const RankingItemValue: FC<RankingItemValueProps> = ({ value }) => (

apps/client/src/components/rankings/stamp.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { FC } from 'react';
1+
import type { FC, ReactNode } from 'react';
22

33
import {
44
RankingItemBar,
@@ -12,7 +12,7 @@ import { useMessageStamps } from '@/hooks/useMessageStamps';
1212
export type StampRankingItemProps = {
1313
stampId: string;
1414
rank: number;
15-
value: number;
15+
value: ReactNode;
1616
rate?: number;
1717
};
1818

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { RankingItemSkeleton } from '@/components/rankings';
2+
import { type DateRange, dateRangeToQuery } from '@/composables/useDateRangePicker';
3+
import type { FC } from 'react';
4+
5+
export type CommonRecapComponentProps = {
6+
userId: string;
7+
year: number;
8+
};
9+
10+
export const yearToDateRange = (year: number) => {
11+
const start = new Date(`${year}-01-01T00:00:00+00:00`);
12+
const end = new Date(`${year}-12-31T23:59:59+00:00`);
13+
return [start, end] satisfies DateRange;
14+
};
15+
16+
export const yearToQuery = (year: number) => dateRangeToQuery(yearToDateRange(year));
17+
18+
export const RankingSkeleton: FC<{ length: number }> = ({ length }) => (
19+
<div className="flex flex-col">
20+
{[...Array(length)].map((_, i) => (
21+
<RankingItemSkeleton key={i} rank={i + 1} />
22+
))}
23+
</div>
24+
);

0 commit comments

Comments
 (0)