Skip to content

Commit 9190eb9

Browse files
committed
feat(members): /me and /runners/[id] stats + SVG pace chart (#39)
1 parent 355ef63 commit 9190eb9

7 files changed

Lines changed: 522 additions & 2 deletions

File tree

app/me/page.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { notFound } from "next/navigation";
2+
3+
import { getMemberStats } from "@/lib/members";
4+
import { requireApproved } from "@/lib/guard";
5+
6+
import { MemberView } from "@/app/runners/_components/MemberView";
7+
8+
export const dynamic = "force-dynamic";
9+
10+
export const metadata = {
11+
title: "내 기록",
12+
};
13+
14+
export default async function MePage() {
15+
const user = await requireApproved();
16+
const stats = await getMemberStats(user.id);
17+
if (!stats) notFound();
18+
return <MemberView stats={stats} isSelf />;
19+
}

app/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ export default async function HomePage() {
3636
전체 아카이브
3737
</Button>
3838
</Link>
39+
<Link href="/me" className="w-full">
40+
<Button className="w-full" variant="outline" size="lg">
41+
내 기록
42+
</Button>
43+
</Link>
3944
</div>
4045

4146
<div className="flex items-center gap-4">

app/runners/[id]/page.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Metadata } from "next";
2+
import { notFound, redirect } from "next/navigation";
3+
4+
import { getMemberStats } from "@/lib/members";
5+
import { requireApproved } from "@/lib/guard";
6+
7+
import { MemberView } from "../_components/MemberView";
8+
9+
export const dynamic = "force-dynamic";
10+
11+
type Props = {
12+
params: Promise<{ id: string }>;
13+
};
14+
15+
export async function generateMetadata({
16+
params,
17+
}: Props): Promise<Metadata> {
18+
const { id } = await params;
19+
const stats = await getMemberStats(id);
20+
if (!stats) return {};
21+
return {
22+
title: `${stats.name}의 기록`,
23+
description: `${stats.name} · 참여 ${stats.participationCount}회 · 누적 ${stats.totalDistanceKm.toFixed(1)}km`,
24+
};
25+
}
26+
27+
export default async function RunnerPage({ params }: Props) {
28+
const me = await requireApproved();
29+
const { id } = await params;
30+
// 자기 자신 id 로 들어오면 /me 로 리다이렉트 (canonical URL 1개로 일원화).
31+
if (id === me.id) redirect("/me");
32+
33+
const stats = await getMemberStats(id);
34+
if (!stats) notFound();
35+
return <MemberView stats={stats} isSelf={false} />;
36+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import Link from "next/link";
2+
3+
import {
4+
formatDistanceKm,
5+
formatDurationSec,
6+
formatPace,
7+
} from "@/lib/pace";
8+
import type { MemberStats } from "@/lib/members";
9+
10+
import { PaceChart } from "./PaceChart";
11+
12+
type Props = {
13+
stats: MemberStats;
14+
/** 현재 보고 있는 사람이 본인 페이지인지 (헤더 라벨에만 영향). */
15+
isSelf: boolean;
16+
};
17+
18+
export function MemberView({ stats, isSelf }: Props) {
19+
return (
20+
<main className="container mx-auto max-w-2xl p-6">
21+
<nav className="mb-4 text-xs text-muted-foreground">
22+
<Link href="/" className="underline-offset-4 hover:underline">
23+
← 홈
24+
</Link>
25+
<span className="mx-2">·</span>
26+
<Link
27+
href="/sessions"
28+
className="underline-offset-4 hover:underline"
29+
>
30+
아카이브
31+
</Link>
32+
</nav>
33+
34+
<header className="mb-6 flex flex-col gap-1">
35+
<h1 className="text-2xl font-bold tracking-tight">
36+
{stats.name}
37+
{stats.role === "ADMIN" && (
38+
<span className="ml-2 rounded bg-secondary px-1.5 py-0.5 text-xs">
39+
ADMIN
40+
</span>
41+
)}
42+
</h1>
43+
<p className="text-xs text-muted-foreground">
44+
{isSelf ? "내 기록" : "러너 기록"} · 가입{" "}
45+
{new Intl.DateTimeFormat("ko-KR", {
46+
year: "numeric",
47+
month: "2-digit",
48+
day: "2-digit",
49+
}).format(stats.joinedAt)}
50+
</p>
51+
</header>
52+
53+
<section className="mb-8 grid grid-cols-3 gap-3">
54+
<Kpi
55+
label="누적 거리"
56+
value={
57+
stats.totalDistanceKm > 0
58+
? `${stats.totalDistanceKm.toFixed(1)} km`
59+
: "—"
60+
}
61+
/>
62+
<Kpi label="참여 횟수" value={`${stats.participationCount} 회`} />
63+
<Kpi
64+
label="평균 페이스"
65+
value={formatPace(stats.avgPaceSecPerKm)}
66+
mono
67+
/>
68+
</section>
69+
70+
<section className="mb-8 flex flex-col gap-3 rounded-md border p-4">
71+
<header className="flex items-baseline justify-between">
72+
<h2 className="text-sm font-semibold uppercase text-muted-foreground">
73+
최근 페이스 추이
74+
</h2>
75+
{stats.paceHistory.length > 0 && (
76+
<span className="text-xs text-muted-foreground">
77+
최근 {stats.paceHistory.length}
78+
</span>
79+
)}
80+
</header>
81+
<PaceChart points={stats.paceHistory} />
82+
</section>
83+
84+
<section className="flex flex-col gap-3">
85+
<h2 className="text-sm font-semibold uppercase text-muted-foreground">
86+
최근 참여 ({stats.recent.length})
87+
</h2>
88+
{stats.recent.length === 0 ? (
89+
<p className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
90+
아직 참여한 세션이 없어요.
91+
</p>
92+
) : (
93+
<ul className="flex flex-col divide-y rounded-md border">
94+
{stats.recent.map((r) => (
95+
<li
96+
key={`${r.sessionId}-${r.date.toISOString()}`}
97+
className="flex flex-col gap-1 p-3 sm:flex-row sm:items-center sm:justify-between"
98+
>
99+
<Link
100+
href={`/sessions/${r.sessionId}`}
101+
className="flex flex-col gap-0.5"
102+
>
103+
<span className="text-sm font-medium">
104+
{new Intl.DateTimeFormat("ko-KR", {
105+
year: "numeric",
106+
month: "2-digit",
107+
day: "2-digit",
108+
}).format(r.date)}
109+
</span>
110+
<span className="text-xs text-muted-foreground">
111+
{r.location}
112+
</span>
113+
</Link>
114+
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs text-muted-foreground">
115+
<span>{formatDistanceKm(r.distanceKm)}</span>
116+
<span>{formatDurationSec(r.durationSec)}</span>
117+
<span className="font-mono">
118+
{formatPace(r.paceSecPerKm)}
119+
</span>
120+
</div>
121+
</li>
122+
))}
123+
</ul>
124+
)}
125+
</section>
126+
</main>
127+
);
128+
}
129+
130+
function Kpi({
131+
label,
132+
value,
133+
mono,
134+
}: {
135+
label: string;
136+
value: string;
137+
mono?: boolean;
138+
}) {
139+
return (
140+
<div className="flex flex-col gap-1 rounded-md border p-3">
141+
<span className="text-xs uppercase text-muted-foreground">{label}</span>
142+
<span className={`text-lg font-semibold ${mono ? "font-mono" : ""}`}>
143+
{value}
144+
</span>
145+
</div>
146+
);
147+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import type { PaceHistoryPoint } from "@/lib/members";
2+
3+
import { formatPace } from "@/lib/pace";
4+
5+
// SVG line chart — 추가 라이브러리 없이 한 화면 한 차트만 필요해서 직접 그림.
6+
// y 축: 페이스 (sec/km, 낮을수록 빠름). x 축: 참여 순서 (시간순).
7+
//
8+
// 단일 포인트면 점 하나만, 0 포인트면 placeholder.
9+
10+
type Props = {
11+
points: PaceHistoryPoint[];
12+
};
13+
14+
const VB_WIDTH = 600;
15+
const VB_HEIGHT = 240;
16+
const PAD_TOP = 24;
17+
const PAD_RIGHT = 24;
18+
const PAD_BOTTOM = 32;
19+
const PAD_LEFT = 64;
20+
21+
export function PaceChart({ points }: Props) {
22+
if (points.length === 0) {
23+
return (
24+
<div className="flex aspect-[5/2] w-full items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground">
25+
페이스 데이터가 아직 없어요.
26+
</div>
27+
);
28+
}
29+
30+
const innerW = VB_WIDTH - PAD_LEFT - PAD_RIGHT;
31+
const innerH = VB_HEIGHT - PAD_TOP - PAD_BOTTOM;
32+
33+
const paces = points.map((p) => p.paceSecPerKm);
34+
const minPace = Math.min(...paces);
35+
const maxPace = Math.max(...paces);
36+
// y axis: padding 으로 약간 여유.
37+
const yMin = Math.max(0, minPace - 15);
38+
const yMax = maxPace + 15;
39+
const ySpan = yMax - yMin || 1;
40+
41+
const xCoord = (i: number) => {
42+
if (points.length === 1) return innerW / 2;
43+
return (i * innerW) / (points.length - 1);
44+
};
45+
const yCoord = (pace: number) =>
46+
innerH - ((pace - yMin) / ySpan) * innerH;
47+
48+
const linePath = points
49+
.map((p, i) => `${i === 0 ? "M" : "L"} ${xCoord(i)} ${yCoord(p.paceSecPerKm)}`)
50+
.join(" ");
51+
52+
// y axis 눈금: min/mid/max
53+
const yTicks = [yMin, (yMin + yMax) / 2, yMax];
54+
55+
// x axis 라벨: 첫/마지막 날짜
56+
const firstDate = points[0].date;
57+
const lastDate = points[points.length - 1].date;
58+
59+
return (
60+
<svg
61+
viewBox={`0 0 ${VB_WIDTH} ${VB_HEIGHT}`}
62+
role="img"
63+
aria-label="최근 페이스 추이"
64+
className="w-full text-foreground"
65+
>
66+
<g transform={`translate(${PAD_LEFT} ${PAD_TOP})`}>
67+
{/* grid */}
68+
{yTicks.map((tick) => (
69+
<line
70+
key={tick}
71+
x1={0}
72+
y1={yCoord(tick)}
73+
x2={innerW}
74+
y2={yCoord(tick)}
75+
stroke="currentColor"
76+
strokeOpacity={0.1}
77+
strokeDasharray="3 3"
78+
/>
79+
))}
80+
{/* axes */}
81+
<line
82+
x1={0}
83+
y1={innerH}
84+
x2={innerW}
85+
y2={innerH}
86+
stroke="currentColor"
87+
strokeOpacity={0.3}
88+
/>
89+
<line
90+
x1={0}
91+
y1={0}
92+
x2={0}
93+
y2={innerH}
94+
stroke="currentColor"
95+
strokeOpacity={0.3}
96+
/>
97+
98+
{/* y labels */}
99+
{yTicks.map((tick) => (
100+
<text
101+
key={tick}
102+
x={-8}
103+
y={yCoord(tick) + 4}
104+
textAnchor="end"
105+
fontSize={11}
106+
fill="currentColor"
107+
opacity={0.6}
108+
>
109+
{formatPace(tick)}
110+
</text>
111+
))}
112+
113+
{/* line */}
114+
<path
115+
d={linePath}
116+
fill="none"
117+
stroke="currentColor"
118+
strokeWidth={2}
119+
strokeOpacity={0.85}
120+
/>
121+
122+
{/* points */}
123+
{points.map((p, i) => (
124+
<circle
125+
key={p.sessionId}
126+
cx={xCoord(i)}
127+
cy={yCoord(p.paceSecPerKm)}
128+
r={3.5}
129+
fill="currentColor"
130+
/>
131+
))}
132+
</g>
133+
134+
{/* x axis label */}
135+
<text
136+
x={PAD_LEFT}
137+
y={VB_HEIGHT - 10}
138+
textAnchor="start"
139+
fontSize={11}
140+
fill="currentColor"
141+
opacity={0.6}
142+
>
143+
{formatShortDate(firstDate)}
144+
</text>
145+
<text
146+
x={VB_WIDTH - PAD_RIGHT}
147+
y={VB_HEIGHT - 10}
148+
textAnchor="end"
149+
fontSize={11}
150+
fill="currentColor"
151+
opacity={0.6}
152+
>
153+
{formatShortDate(lastDate)}
154+
</text>
155+
</svg>
156+
);
157+
}
158+
159+
function formatShortDate(d: Date): string {
160+
return new Intl.DateTimeFormat("ko-KR", {
161+
month: "2-digit",
162+
day: "2-digit",
163+
}).format(d);
164+
}

0 commit comments

Comments
 (0)