Skip to content

Commit ecb765d

Browse files
committed
feat(sessions): archive list /sessions with cursor pagination (#30)
Closes #11. + workflow docs 보강 (PR #28/29 사고 학습 반영). /sessions: - requireApproved 가드, 최신순 (date desc, id desc) keyset pagination, PAGE_SIZE=20, take +1 으로 다음 페이지 존재 판별 - SessionCard: next/image 16:9 썸네일 (없으면 회색 placeholder), 날짜·장소·참여자수·호스트 - EmptyState: 빈 아카이브 시 CTA - '더 보기' = Link 로 ?cursor=<id> 갱신 (JS 없이도 동작) - lib/sessions.ts 신설 — listSessions(cursorId) 도메인 쿼리 한 곳에 홈 화면: - '아카이브 리스트는 다음 PR' placeholder 제거 - '전체 아카이브' Link 활성화 워크플로우 보강 (같은 PR 안에 의도적 묶음): - .gjc/skills/murun-feature/SKILL.md '4.5 폼/입력이 들어가는 경우' 추가: negative case alert / useActionState / useFormStatus / DB unique 등 강제 체크리스트. PR #28/#29 누락 사고 재발 방지. - docs/wiki/06-Checkpoints.md '#7 End-to-end smoke' 에 negative case 명시 추가 — '빌드 그린은 시작점' 표어. Verified: - pnpm typecheck/lint/build 통과 (10 routes 정상) - /sessions 173B + 111kB First Load (cursor URL 만으로 동작) - E2E (세션 0/1/21+ 케이스, 사진 유무, 모바일) 는 머지 후 사용자 Out of scope (별 PR): - 검색·필터 (#14) - 사진 업로드 폼의 inline alert + dedupe (PR #29 OOS 그대로 — UX 결 다름) - 무한 스크롤 - 시즌/월별 그룹화 Spec: .gjc/specs/sessions-archive-list.md
1 parent 6fb4bb8 commit ecb765d

8 files changed

Lines changed: 331 additions & 3 deletions

File tree

.gjc/skills/murun-feature/SKILL.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,23 @@ git switch -c feat/<issue-num>-<slug>
5454
4. `loading.tsx`, `error.tsx`, 빈 상태 처리 누락 없는지 확인
5555
5. `tests/<feature>.test.ts` 스모크 1개 (도메인 함수 위주, e2e는 욕심내지 않음)
5656

57+
### 4.5 폼/입력이 들어가는 경우 (강제)
58+
59+
새로운 form 또는 사용자 입력 필드를 추가할 때 다음을 **체크 없이 진행 금지**.
60+
PR #28, #29 의 사고 (silent noop, 더블 클릭 row 중복) 가 이 단계 누락에서 옴.
61+
62+
- [ ] **모든 필드의 negative case** 가 inline alert 로 표시되는가
63+
- 빈 입력 (특히 "최소 한 가지" 조건)
64+
- 음수 / 범위 밖 (예: 초 60 이상)
65+
- 타입 불일치 (숫자 자리에 문자)
66+
- 길이 초과
67+
- [ ] **server action 시그니처**`useActionState` 패턴 (`(prev, formData) => Promise<{ ok, error? }>`)
68+
- [ ] **client form component**`useActionState` 로 상태 받고 `ErrorAlert` 로 메시지 표시
69+
- [ ] **submit 버튼**`useFormStatus` 기반 (`SubmitButton`) — pending 중 disabled + 라벨 교체
70+
- [ ] **중복 생성 방지**: DB 수준 unique constraint (or 트랜잭션) 가 2차 안전망으로 존재
71+
- [ ] 검증 로직은 **server action 안의 zod 또는 parseOptionalNumber** — client 검증만 의존 X (우회 방지)
72+
- [ ] (해당 시) 검증 실패 시 입력값이 사라지지 않는지 — 부분 보존이라도 OK
73+
5774
### 5. Self-check
5875
[`docs/wiki/06-Checkpoints.md`](../../../docs/wiki/06-Checkpoints.md) 의 7개 항목을 PR 본문에 박은 채로 사용자가 직접 확인하도록 둔다. Agent는 박스를 자기가 채우지 않는다.
5976

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Spec · sessions-archive-list
2+
3+
> #11. 세션 아카이브 리스트 `/sessions`. 시즌이 쌓일수록 자라나는 페이지.
4+
> 같은 PR 안에 워크플로우 개선 사항 (SKILL.md, 06-Checkpoints) 함께 박음.
5+
6+
## Story
7+
8+
승인된 멤버가 `/sessions` 진입하면 최신순 카드 리스트를 본다. 각 카드 = 단체사진 썸네일 + 날짜 + 장소 + 참여자 수. 카드 클릭 → 상세. 비어 있으면 "아직 활동 기록이 없어요." + CTA. 홈에서 "전체 아카이브" 링크가 활성화된다.
9+
10+
## Closes
11+
12+
- #11 세션 아카이브 리스트 (/sessions)
13+
14+
## Acceptance
15+
16+
### 페이지
17+
- [ ] `/sessions` 라우트, `requireApproved` 가드
18+
- [ ] 카드 = 사진 (있으면 next/image 16:9, 없으면 회색 placeholder) + 날짜 (yyyy-MM-dd 요일) + 장소 + 참여자 수 + (있으면) 호스트 이름
19+
- [ ] 정렬: `date desc, createdAt desc` (같은 날짜면 최근 생성순)
20+
- [ ] 빈 상태: 텍스트 + "새 세션 만들기" CTA
21+
- [ ] 모바일 375px: 1열, 데스크탑: 1열 유지 (사진 가독성 위해 좁은 column 카드형)
22+
- [ ] 카드는 `<Link href={`/sessions/${id}`}>` 로 wrap
23+
24+
### 페이지네이션
25+
- [ ] 페이지당 20개. URL `?cursor=<id>` 로 다음 페이지. cursor 기반 keyset pagination.
26+
- [ ] 다음 페이지 있으면 하단 "더 보기" 버튼 (`<Link>` 로 query 갱신, JS 없이도 동작)
27+
- [ ] 첫 페이지 진입 시 cursor 없음
28+
29+
### 홈 화면 업데이트
30+
- [ ] `app/page.tsx` 의 "아카이브 리스트는 다음 PR에서" placeholder 제거
31+
- [ ] "전체 아카이브" 링크 활성화 (`/sessions`)
32+
33+
### 데이터 쿼리 (`lib/sessions.ts` 신설)
34+
- [ ] `listSessions({ cursorId? }): { items, nextCursor }` — 도메인 쿼리를 한 곳에 모음
35+
- [ ] `getSessionDetail(id)` 도 같이 옮김 (현재 페이지 안에 inline 으로 박혀 있음) — 다음 PR 에서 통합 가능, 이번 PR 은 listSessions 만
36+
37+
### 워크플로우 보강 (같은 PR 안에)
38+
39+
- [ ] `.gjc/skills/murun-feature/SKILL.md` 에 "Step 4.5 — 폼/입력 표준 체크리스트" 추가
40+
- [ ] `docs/wiki/06-Checkpoints.md``#7 End-to-end smoke` 항목에 negative case 명시 추가
41+
42+
## Out of scope
43+
44+
- 검색·필터 (참여 인원 수 등) — Week 3 (#14)
45+
- 월/시즌 그룹화 — Week 3
46+
- 무한 스크롤 (intersection observer) — 더 보기 버튼으로 충분
47+
- 사진 lazy load — `next/image` 가 알아서
48+
- OG 이미지 — Week 3 (#15)
49+
- 아카이브 카드에 "내가 참여" 뱃지 — 별 PR
50+
- `lib/sessions.ts` 로 getSessionDetail 통합 — 별 PR
51+
52+
## Touched files (예상)
53+
54+
| 파일 | new/edit | 요지 |
55+
|------|----------|------|
56+
| `lib/sessions.ts` | new | listSessions(cursor) keyset pagination |
57+
| `app/sessions/page.tsx` | new | 아카이브 리스트 + 더 보기 |
58+
| `app/sessions/_components/SessionCard.tsx` | new | 카드 1개 |
59+
| `app/sessions/_components/EmptyState.tsx` | new | 빈 상태 |
60+
| `app/page.tsx` | edit | "전체 아카이브" 링크 활성화 |
61+
| `.gjc/skills/murun-feature/SKILL.md` | edit | 폼/입력 표준 체크리스트 |
62+
| `docs/wiki/06-Checkpoints.md` | edit | #7 negative case |
63+
64+
## Verification (사용자가 직접)
65+
66+
머지 후:
67+
1. `/` 에서 "전체 아카이브" 링크 → `/sessions` 진입
68+
2. 빈 상태일 때 (세션 0개) → "아직 활동 기록이 없어요." + CTA 노출
69+
3. 세션 1~5개 만든 상태에서 → 카드 리스트, 최신순
70+
4. 21개 이상이면 하단 "더 보기" 버튼 → 클릭 시 `?cursor=<id>` URL, 다음 페이지 표시
71+
5. 사진 있는 세션은 썸네일, 없는 세션은 placeholder
72+
6. 모바일 375px: 카드 1열, 가로 스크롤 없음
73+
7. 비승인 사용자 진입 시 `/pending`

app/page.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ export default async function HomePage() {
3131
새 세션 만들기
3232
</Button>
3333
</Link>
34-
<p className="text-center text-xs text-muted-foreground">
35-
아카이브 리스트는 다음 PR에서 추가됩니다.
36-
</p>
34+
<Link href="/sessions" className="w-full">
35+
<Button className="w-full" variant="outline" size="lg">
36+
전체 아카이브
37+
</Button>
38+
</Link>
3739
</div>
3840

3941
<div className="flex items-center gap-4">
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Link from "next/link";
2+
3+
import { Button } from "@/components/ui/button";
4+
5+
export function EmptyState() {
6+
return (
7+
<section className="flex flex-col items-center gap-4 rounded-md border border-dashed p-10 text-center">
8+
<p className="text-sm text-muted-foreground">
9+
아직 활동 기록이 없어요.
10+
<br />첫 세션을 만들어 시즌을 시작하세요.
11+
</p>
12+
<Link href="/sessions/new">
13+
<Button>새 세션 만들기</Button>
14+
</Link>
15+
</section>
16+
);
17+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import Image from "next/image";
2+
import Link from "next/link";
3+
4+
type Props = {
5+
id: number;
6+
date: Date;
7+
startTime: string | null;
8+
location: string;
9+
weather: string | null;
10+
groupPhotoPath: string | null;
11+
hostName: string;
12+
participantCount: number;
13+
};
14+
15+
export function SessionCard(props: Props) {
16+
const photoSrc = props.groupPhotoPath
17+
? `/api/uploads/${encodePath(props.groupPhotoPath)}`
18+
: null;
19+
20+
return (
21+
<Link
22+
href={`/sessions/${props.id}`}
23+
className="block overflow-hidden rounded-md border bg-card transition-colors hover:bg-accent/30"
24+
>
25+
<div className="relative aspect-video w-full bg-muted">
26+
{photoSrc ? (
27+
<Image
28+
src={photoSrc}
29+
alt={`${formatDate(props.date)} ${props.location} 단체사진`}
30+
fill
31+
unoptimized
32+
className="object-cover"
33+
sizes="(max-width: 768px) 100vw, 480px"
34+
/>
35+
) : (
36+
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
37+
사진 없음
38+
</div>
39+
)}
40+
</div>
41+
<div className="flex flex-col gap-1 p-3">
42+
<div className="flex items-baseline justify-between gap-2">
43+
<span className="font-semibold">{formatDate(props.date)}</span>
44+
<span className="text-xs text-muted-foreground">
45+
참여 {props.participantCount}
46+
</span>
47+
</div>
48+
<p className="text-sm">{props.location}</p>
49+
<p className="text-xs text-muted-foreground">
50+
{props.startTime && <span>시작 {props.startTime} · </span>}
51+
{props.weather && <span>{props.weather} · </span>}
52+
호스트 {props.hostName}
53+
</p>
54+
</div>
55+
</Link>
56+
);
57+
}
58+
59+
function formatDate(d: Date): string {
60+
return new Intl.DateTimeFormat("ko-KR", {
61+
year: "numeric",
62+
month: "2-digit",
63+
day: "2-digit",
64+
weekday: "short",
65+
}).format(d);
66+
}
67+
68+
function encodePath(relPath: string): string {
69+
return relPath
70+
.split("/")
71+
.map((s) => encodeURIComponent(s))
72+
.join("/");
73+
}

app/sessions/page.tsx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Link from "next/link";
2+
3+
import { requireApproved } from "@/lib/guard";
4+
import { listSessions } from "@/lib/sessions";
5+
import { Button } from "@/components/ui/button";
6+
7+
import { EmptyState } from "./_components/EmptyState";
8+
import { SessionCard } from "./_components/SessionCard";
9+
10+
export const dynamic = "force-dynamic";
11+
12+
type PageProps = {
13+
searchParams: Promise<{ cursor?: string }>;
14+
};
15+
16+
export default async function SessionsArchivePage({ searchParams }: PageProps) {
17+
await requireApproved();
18+
19+
const { cursor } = await searchParams;
20+
const cursorId = parseCursor(cursor);
21+
22+
const page = await listSessions({ cursorId });
23+
24+
return (
25+
<main className="container mx-auto max-w-2xl p-6">
26+
<nav className="mb-4 text-xs text-muted-foreground">
27+
<Link href="/" className="underline-offset-4 hover:underline">
28+
← 홈
29+
</Link>
30+
</nav>
31+
32+
<header className="mb-6 flex items-center justify-between">
33+
<h1 className="text-2xl font-bold tracking-tight">아카이브</h1>
34+
<Link href="/sessions/new">
35+
<Button size="sm">새 세션</Button>
36+
</Link>
37+
</header>
38+
39+
{page.items.length === 0 ? (
40+
<EmptyState />
41+
) : (
42+
<>
43+
<ul className="flex flex-col gap-4">
44+
{page.items.map((s) => (
45+
<li key={s.id}>
46+
<SessionCard {...s} />
47+
</li>
48+
))}
49+
</ul>
50+
51+
{page.nextCursorId != null && (
52+
<div className="mt-6 flex justify-center">
53+
<Link href={`/sessions?cursor=${page.nextCursorId}`}>
54+
<Button variant="outline">더 보기</Button>
55+
</Link>
56+
</div>
57+
)}
58+
</>
59+
)}
60+
</main>
61+
);
62+
}
63+
64+
function parseCursor(raw: string | undefined): number | undefined {
65+
if (!raw) return undefined;
66+
const n = Number.parseInt(raw, 10);
67+
return Number.isFinite(n) && n > 0 ? n : undefined;
68+
}

docs/wiki/06-Checkpoints.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@
5757
- staging 또는 로컬에서 진짜 시나리오를 1회 끝까지 흘려본 흔적이 있는가.
5858
- 권한 변경이면 **두 계정**(예: ADMIN + 일반 멤버)으로 각각 진입해 봤는가.
5959
- 모바일 변경이면 실제 모바일 또는 DevTools 모바일 모드에서 봤는가.
60+
- **검증 위치**: PR open 중엔 `feat/*` 브랜치, 머지된 변경은 `dev` 브랜치에서 회귀 1회.
61+
- **Happy path 만으로는 부족.** 폼/입력이 있으면 다음 negative case 를 머릿속에서라도 흘려본다 (PR #28/#29 에서 누락한 항목):
62+
- 빈 입력 (특히 "최소 한 가지" 조건의 폼)
63+
- 잘못된 타입 / 음수 / 범위 밖 (예: 초 60 이상)
64+
- 빠른 더블/연속 클릭으로 row 중복 생성
65+
- 권한 없는 사용자로 동일 페이지 진입
66+
- 인증 만료 / 쿠키 삭제 후 새로고침
67+
- Agent 가 "코드 측 검증 통과" 라고 보고하는 것 ≠ 사용자가 동작 확인. **빌드 그린은 시작점**.
6068

6169
---
6270

lib/sessions.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// 세션 도메인 쿼리.
2+
//
3+
// keyset pagination 사용 — 큰 데이터에도 안정적이고 cursor URL 이 공유 가능.
4+
// 정렬은 (date desc, id desc). id 도 같이 두는 이유: 같은 날짜의 안정적 tie-break.
5+
6+
import { db } from "@/lib/db";
7+
8+
export const PAGE_SIZE = 20;
9+
10+
export type SessionCard = {
11+
id: number;
12+
date: Date;
13+
startTime: string | null;
14+
location: string;
15+
weather: string | null;
16+
groupPhotoPath: string | null;
17+
hostName: string;
18+
participantCount: number;
19+
};
20+
21+
export type SessionListPage = {
22+
items: SessionCard[];
23+
nextCursorId: number | null;
24+
};
25+
26+
/**
27+
* 최신순 keyset pagination.
28+
* - cursorId 미지정: 첫 페이지
29+
* - cursorId 지정: 그 id 보다 *오래된* (id 가 작은) 세션부터 PAGE_SIZE 개
30+
*/
31+
export async function listSessions(opts: {
32+
cursorId?: number;
33+
}): Promise<SessionListPage> {
34+
const where = opts.cursorId ? { id: { lt: opts.cursorId } } : undefined;
35+
36+
const rows = await db.session.findMany({
37+
where,
38+
orderBy: [{ date: "desc" }, { id: "desc" }],
39+
take: PAGE_SIZE + 1, // 다음 페이지 존재 여부 판별용으로 +1
40+
select: {
41+
id: true,
42+
date: true,
43+
startTime: true,
44+
location: true,
45+
weather: true,
46+
groupPhotoPath: true,
47+
host: { select: { name: true } },
48+
_count: { select: { participations: true } },
49+
},
50+
});
51+
52+
const hasNext = rows.length > PAGE_SIZE;
53+
const visible = hasNext ? rows.slice(0, PAGE_SIZE) : rows;
54+
const items: SessionCard[] = visible.map((r) => ({
55+
id: r.id,
56+
date: r.date,
57+
startTime: r.startTime,
58+
location: r.location,
59+
weather: r.weather,
60+
groupPhotoPath: r.groupPhotoPath,
61+
hostName: r.host.name,
62+
participantCount: r._count.participations,
63+
}));
64+
65+
const nextCursorId = hasNext
66+
? (visible[visible.length - 1]?.id ?? null)
67+
: null;
68+
69+
return { items, nextCursorId };
70+
}

0 commit comments

Comments
 (0)