From 33edd146c886f83ccf8ccc952626cbac22a7fb63 Mon Sep 17 00:00:00 2001 From: Ryu Tae Hyun Date: Mon, 31 Aug 2026 21:23:09 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat(queue):=20=EB=82=B4=20=EB=85=B8?= =?UTF-8?q?=EB=9E=98=20=EC=9E=AC=EC=83=9D=20=EA=B8=B0=EB=A1=9D=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api-contract.md | 3 +- .../delivery-state.md | 10 +- .../plan.md | 12 +- .../qa-report.md | 31 +++++ .../ui-flow.md | 7 +- docs/exec-plans/active/README.md | 2 +- src/features/playlist/model/types.ts | 5 +- .../model/useRoomQueueHistory.test.tsx | 3 +- .../queue/hooks/useRoomQueuePanel.test.tsx | 121 +++++++++++++++++- .../room/queue/hooks/useRoomQueuePanel.ts | 59 ++++++--- .../room/queue/model/roomQueue.test.ts | 34 ++++- src/features/room/queue/model/roomQueue.ts | 21 ++- .../room/queue/ui/RoomQueueCard.module.css | 5 + .../room/queue/ui/RoomQueueHistoryCard.tsx | 1 + .../queue/ui/RoomQueueListSection.test.tsx | 38 +++++- .../room/queue/ui/RoomQueueListSection.tsx | 43 +++---- .../room/queue/ui/RoomQueuePanelView.test.tsx | 16 +++ .../room/queue/ui/RoomQueuePanelView.tsx | 25 ++-- .../queue/ui/RoomQueueVirtualization.test.tsx | 3 +- .../ui/useQueueBidirectionalScroll.test.tsx | 76 ++++++++++- .../queue/ui/useQueueBidirectionalScroll.ts | 83 +++++++----- 21 files changed, 492 insertions(+), 106 deletions(-) create mode 100644 docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/qa-report.md diff --git a/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/api-contract.md b/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/api-contract.md index 779279bc..21345e91 100644 --- a/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/api-contract.md +++ b/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/api-contract.md @@ -7,8 +7,9 @@ - query: 첫 페이지도 `size=100`; 다음 페이지는 숫자 `cursorId`와 `size=100` - every request forwards React Query `AbortSignal` - response: `ApiResponse<{ items, hasNext, nextCursor }>` unwrap -- entry: numeric `id`, flat track metadata, timing fields, and `source: USER_REQUESTED | AUTOMATIC_REPLAY` +- entry: numeric `id`, flat track metadata, timing fields, and `playbackOrigin: USER_REQUESTED | AUTOMATIC_REPLAY` - backend page/item order is newest-first; selector exposes oldest-first +- 내 노래 탭은 `playbackOrigin=USER_REQUESTED`이면서 응답의 nullable `addedByUserSlug`와 현재 사용자의 공개 slug가 정확히 일치하는 항목만 소비하며, nickname이나 숫자 ID로 대체하지 않는다. - `hasNext=false`, missing cursor, or an already requested cursor stops pagination - TanStack infinite query stores at most 5 pages and reset returns to the newest first page diff --git a/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/delivery-state.md b/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/delivery-state.md index aef01ccb..cf9ccb79 100644 --- a/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/delivery-state.md +++ b/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/delivery-state.md @@ -1,12 +1,12 @@ # Delivery State -- status: ci-pending +- status: publishing - branch: dev - base: main - issue: -- pr: https://github.com/Queuing-org/frontend/pull/57 +- pr: pending successor to merged PR #57 - selected_skills: queuing-feature-delivery, queuing-orchestrator, queuing-api-boundary, queuing-ui-flow, frontend-architecture-guardrails, queuing-qa-reviewer -- local_qa: test 152/621 passed; lint passed; build passed; fresh QA pass -- ci: pending +- local_qa: test 152/628 passed; lint passed; build passed; diff-check passed; fresh QA pass +- ci: not-started - review_threads: not-started -- next_action: monitor PR #57 checks and review feedback +- next_action: dev push 후 successor Draft PR 생성 diff --git a/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/plan.md b/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/plan.md index 4cf5cd4d..ee824d69 100644 --- a/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/plan.md +++ b/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/plan.md @@ -6,6 +6,8 @@ - 과거곡은 `queue-history`, 대기곡과 내 신청곡은 기존 cursor query를 스크롤 경계에서 한 페이지씩 조회한다. - history 5페이지/500곡, 각 목록 DOM 40행 상한과 대기곡 한정 DnD를 유지한다. - 곡 전환·재연결·종료 시 history 최신 창과 관련 캐시를 일관되게 갱신한다. +- 내 노래 탭에도 로그인 사용자가 신청한 history와 현재곡을 같은 시간축으로 노출한다. +- 지난 곡 카드는 현재 재생 카드와 같은 회색 배경으로 구분한다. ## Selected skills @@ -21,7 +23,9 @@ - history API가 slug, access token, `cursorId`, `size=100`, AbortSignal 계약을 지킨다. - 누락·반복 cursor를 중단하고 history 응답을 중복 제거한 시간순 최대 500곡으로 만든다. - 전체 탭의 현재곡 앵커, 상단 prepend 보정, 하단 자동 조회, 방향별 실패/재시도가 동작한다. -- 내 신청곡 탭은 곡 전환 때 탭과 스크롤을 유지하며 DnD 중에는 자동 조회하지 않는다. +- 내 신청곡 탭은 공개 slug가 일치하는 history/current만 노출하고 DnD 중에는 자동 조회하지 않는다. +- 전체/내 노래 양쪽에서 위로 과거곡, 아래로 대기곡 페이지를 조회한다. +- 지난 곡은 현재 재생 카드와 같은 `#f7f7f9` 배경을 사용한다. - track 이벤트·직접 skip·재연결은 history를 첫 페이지로 reset하고 terminal cleanup은 history 캐시도 제거한다. - `npm run test`, `npm run lint`, `npm run build`와 fresh QA review가 통과한다. @@ -30,6 +34,7 @@ 1. `feat(playlist): 재생 기록 조회와 캐시 생명주기 추가` 2. `feat(queue): 재생목록 양방향 무한스크롤 적용` 3. `docs(delivery): 양방향 무한스크롤 게시 상태 기록` +4. `feat(queue): 내 노래 재생 기록 노출` ## Progress @@ -39,12 +44,14 @@ - [x] UI·스크롤·가상화 구현 - [x] targeted/full QA와 fresh review - [x] commit, push, Draft PR +- [x] 내 노래 history 후속 구현과 회귀 QA ## Decisions - React Query가 history 서버 상태와 5페이지 sliding window를 소유한다. - queue panel hook이 탭, 조회 상태, mutation busy 상태를 조합하고 UI가 실제 scroll container와 anchor 보정을 소유한다. - history/current는 정적 비-sortable 구간, pending queue만 기존 sortable 구간으로 둔다. +- 개인 history는 새 API를 추정하지 않고 기존 history 응답의 `playbackOrigin=USER_REQUESTED`, `addedByUserSlug`와 로그인 사용자의 공개 slug를 정확히 비교한다. ## Verification @@ -54,6 +61,9 @@ - fresh read-only QA: `pass`; virtual geometry, stale anchor, retry, refetch busy, tail clamp findings closed - manual browser QA: unavailable because the in-app browser had no connected runtime; no desktop/mobile/public/private claim is made - delivery: `dev` pushed and Draft PR #57 opened against `main` +- follow-up targeted: room/playlist 26 files, 101 tests passed; final focused 2 files, 22 tests passed +- follow-up full QA: 152 files, 628 tests passed; lint passed; build passed; `git diff --check` passed +- follow-up fresh read-only QA: `pass`; 개인 필터, 짧은 목록 wheel, anchor/latch, DnD, 회색 상태 검토 완료 ## Residual risk diff --git a/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/qa-report.md b/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/qa-report.md new file mode 100644 index 00000000..df8dc3f3 --- /dev/null +++ b/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/qa-report.md @@ -0,0 +1,31 @@ +# QA Report + +## Result + +- classification: `pass` +- blocking findings: 없음 + +## Boundary review + +- backend history 응답의 `addedByUserSlug`, `playbackOrigin`, `startOffsetMs`와 frontend type이 일치한다. +- 내 노래 탭은 `USER_REQUESTED`이면서 현재 사용자의 공개 slug와 일치하는 history만 노출한다. +- 자동재생·타인·비로그인 cached history는 개인 목록에서 제외한다. +- 개인 시간축은 history → 본인 현재곡 → 본인 pending 순서이며 DnD와 삭제는 기존 pending 구간에만 유지한다. +- 전체/내 노래 모두 상단 history와 하단 queue 페이지네이션을 사용한다. +- 개인 history가 비어 스크롤 높이가 생기지 않아도 위쪽 wheel로 다음 과거 페이지를 요청한다. +- history와 active current는 같은 `#f7f7f9` 배경을 사용한다. +- 목록별 가상화 DOM 40행 상한을 유지한다. + +## Verification + +- `npm run test -- --run src/features/room/queue src/features/playlist`: 26 files, 101 tests passed +- focused final regression: 2 files, 22 tests passed +- `npm run test -- --reporter=dot`: 152 files, 628 tests passed +- `npm run lint`: passed +- `npm run build`: passed +- `git diff --check`: passed +- fresh read-only QA: `pass` + +## Residual risk + +- 연결 가능한 인증 runtime이 없어 실제 공개/비공개 방과 모바일 터치 입력은 브라우저 실측하지 못했다. diff --git a/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/ui-flow.md b/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/ui-flow.md index 664cf757..c94b5e08 100644 --- a/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/ui-flow.md +++ b/docs/exec-plans/active/2026-08-31-bidirectional-queue-scroll/ui-flow.md @@ -20,8 +20,11 @@ ## Mine tab -- no history/current rows. -- current track changes do not switch tabs or scroll. +- 기존 history query를 공유하되 현재 사용자의 공개 slug와 일치하는 history만 렌더링한다. +- 현재곡도 본인 신청곡일 때만 history와 pending 사이에 렌더링한다. +- 진입과 본인 현재곡 변경 시 history/current 경계를 최상단에 맞춘다. +- 지난 곡과 현재곡은 같은 회색 배경을 쓰고 정렬·삭제 대상에서 제외한다. +- 상단에서 개인 history, 하단에서 개인 pending queue를 한 페이지씩 조회한다. - within 96px of bottom, fetch one personal queue page unless loading, refetching, dragging, or mutating. ## Failure and accessibility diff --git a/docs/exec-plans/active/README.md b/docs/exec-plans/active/README.md index 8e7a1ecb..2d758e48 100644 --- a/docs/exec-plans/active/README.md +++ b/docs/exec-plans/active/README.md @@ -1,6 +1,6 @@ # Active Execution Plans -- [2026-08-31-bidirectional-queue-scroll](./2026-08-31-bidirectional-queue-scroll/plan.md): ci-pending — Draft PR #57, queue-history 기반 양방향 무한스크롤과 전체 로컬 QA·fresh review 통과 +- [2026-08-31-bidirectional-queue-scroll](./2026-08-31-bidirectional-queue-scroll/plan.md): publishing — 머지된 PR #57 후속으로 내 노래 history 필터·회색 카드와 전체 로컬 QA·fresh review 통과 - [2026-08-24-stomp-worker-heartbeat](./2026-08-24-stomp-worker-heartbeat/plan.md): ci-pending — Draft PR #56, 공용 STOMP outgoing heartbeat Worker 전환과 전체 로컬 QA·fresh review 통과 diff --git a/src/features/playlist/model/types.ts b/src/features/playlist/model/types.ts index a91c2192..02869bd9 100644 --- a/src/features/playlist/model/types.ts +++ b/src/features/playlist/model/types.ts @@ -113,7 +113,7 @@ export type RoomParticipantsPage = { nextCursor: string | null; }; -export type RoomQueueHistorySource = +export type RoomQueuePlaybackOrigin = | "USER_REQUESTED" | "AUTOMATIC_REPLAY"; @@ -122,9 +122,10 @@ export type RoomQueueHistoryEntry = { title: string; entryId: string; skipped: boolean; + startOffsetMs: number; videoId: string; provider: TrackProvider; - source: RoomQueueHistorySource; + playbackOrigin: RoomQueuePlaybackOrigin; endedAtMs: number; durationMs: number; queuedAtMs: number | null; diff --git a/src/features/playlist/model/useRoomQueueHistory.test.tsx b/src/features/playlist/model/useRoomQueueHistory.test.tsx index e7a20f8e..2b915837 100644 --- a/src/features/playlist/model/useRoomQueueHistory.test.tsx +++ b/src/features/playlist/model/useRoomQueueHistory.test.tsx @@ -42,7 +42,8 @@ function entry(id: number): RoomQueueHistoryEntry { skipped: false, videoId: `video-${id}`, provider: "YOUTUBE", - source: "USER_REQUESTED", + playbackOrigin: "USER_REQUESTED", + startOffsetMs: 0, endedAtMs: id, durationMs: 1_000, queuedAtMs: null, diff --git a/src/features/room/queue/hooks/useRoomQueuePanel.test.tsx b/src/features/room/queue/hooks/useRoomQueuePanel.test.tsx index 0146b453..6f5ed2cb 100644 --- a/src/features/room/queue/hooks/useRoomQueuePanel.test.tsx +++ b/src/features/room/queue/hooks/useRoomQueuePanel.test.tsx @@ -4,6 +4,7 @@ import { useMyRoomQueue } from "@/src/features/playlist/model/useMyRoomQueue"; import { useRoomQueue } from "@/src/features/playlist/model/useRoomQueue"; import { useRoomQueueHistory } from "@/src/features/playlist/model/useRoomQueueHistory"; import { ApiError } from "@/src/shared/api/api-error"; +import type { RoomQueueHistoryEntry } from "@/src/features/playlist/model/types"; import { useRoomQueuePanel } from "./useRoomQueuePanel"; const mocks = vi.hoisted(() => ({ @@ -60,6 +61,26 @@ const pendingEntry = { updatedAtMs: 1, }; +const historyEntry = ( + id: number, + addedByUserSlug: string | null, +): RoomQueueHistoryEntry => ({ + addedByUserSlug, + durationMs: 1_000, + endedAtMs: id, + entryId: `history-${id}`, + id, + provider: "YOUTUBE", + queuedAtMs: 1, + skipped: false, + playbackOrigin: "USER_REQUESTED", + startOffsetMs: 0, + startedAtMs: 2, + thumbnailUrl: null, + title: `지난 곡 ${id}`, + videoId: `history-video-${id}`, +}); + describe("useRoomQueuePanel query visibility", () => { beforeEach(() => { vi.clearAllMocks(); @@ -183,6 +204,104 @@ describe("useRoomQueuePanel query visibility", () => { expect(result.current.myPendingCount).toBeNull(); }); + it("내 노래 탭은 공개 slug가 일치하는 history와 현재곡만 노출한다", () => { + vi.mocked(useRoomQueueHistory).mockReturnValue({ + entries: [ + historyEntry(1, "user"), + historyEntry(2, "other"), + historyEntry(3, null), + { + ...historyEntry(4, "user"), + playbackOrigin: "AUTOMATIC_REPLAY", + }, + ], + error: null, + fetchNextPage: vi.fn(), + hasNextPage: true, + includesLatestPage: true, + isFetchNextPageError: false, + isFetchingNextPage: false, + isLoading: false, + isRefetching: false, + refetch: vi.fn(), + resetToLatestPage: vi.fn(), + } as unknown as ReturnType); + const { result, rerender } = renderHook( + ({ currentEntry }) => + useRoomQueuePanel({ + currentEntry, + currentUser: { + nickname: "사용자", + profileImageUrl: null, + slug: "user", + userId: 1, + }, + isCurrentUserLoading: false, + roomMeta: null, + roomAccessToken: "secret", + roomSlug: "room", + }), + { initialProps: { currentEntry: pendingEntry } }, + ); + + act(() => result.current.setActiveTab("mine")); + + expect(useRoomQueueHistory).toHaveBeenLastCalledWith( + "room", + "secret", + true, + ); + expect(result.current.historyEntries.map(({ id }) => id)).toEqual([1]); + expect(result.current.currentEntry?.entryId).toBe("entry-1"); + + rerender({ + currentEntry: { + ...pendingEntry, + addedBy: { ...pendingEntry.addedBy, slug: "other" }, + }, + }); + expect(result.current.currentEntry).toBeNull(); + }); + + it("비로그인 내 노래 탭은 cached history를 노출하거나 추가 조회하지 않는다", () => { + const fetchNextPage = vi.fn(); + vi.mocked(useRoomQueueHistory).mockReturnValue({ + entries: [historyEntry(1, "user")], + error: new Error("history 실패"), + fetchNextPage, + hasNextPage: true, + includesLatestPage: true, + isFetchNextPageError: false, + isFetchingNextPage: false, + isLoading: false, + isRefetching: false, + refetch: vi.fn(), + resetToLatestPage: vi.fn(), + } as unknown as ReturnType); + const { result } = renderHook(() => + useRoomQueuePanel({ + currentUser: null, + isCurrentUserLoading: false, + roomMeta: null, + roomAccessToken: "secret", + roomSlug: "room", + }), + ); + + act(() => result.current.setActiveTab("mine")); + result.current.loadNextHistoryPage(); + + expect(useRoomQueueHistory).toHaveBeenLastCalledWith( + "room", + "secret", + false, + ); + expect(result.current.historyEntries).toEqual([]); + expect(result.current.hasNextHistoryPage).toBe(false); + expect(result.current.historyErrorMessage).toBe(""); + expect(fetchNextPage).not.toHaveBeenCalled(); + }); + it("곡 삭제 성공은 알림 없이 목록 갱신 결과만 사용한다", () => { const { result } = renderHook(() => useRoomQueuePanel({ @@ -337,7 +456,7 @@ describe("useRoomQueuePanel query visibility", () => { expect(useRoomQueueHistory).toHaveBeenLastCalledWith( "room", "secret", - false, + true, ); await act(() => result.current.handleMoveMyEntry({ diff --git a/src/features/room/queue/hooks/useRoomQueuePanel.ts b/src/features/room/queue/hooks/useRoomQueuePanel.ts index c43583f3..beb224a2 100644 --- a/src/features/room/queue/hooks/useRoomQueuePanel.ts +++ b/src/features/room/queue/hooks/useRoomQueuePanel.ts @@ -16,6 +16,7 @@ import type { User } from "@/src/features/user/model/types"; import { isEntryRequestedByUser, getPendingPersonalQueueEntryIds, + isHistoryEntryRequestedByUser, isPendingQueueEntry, isValidPersonalQueueMove, type QueueTab, @@ -59,10 +60,11 @@ export function useRoomQueuePanel({ const [activeTab, setActiveTab] = useState("all"); const { notify } = useActionFeedback(); const allQueueQuery = useRoomQueue(roomSlug, roomAccessToken); + const isHistoryEnabled = activeTab === "all" || Boolean(currentUser); const historyQuery = useRoomQueueHistory( roomSlug, roomAccessToken, - activeTab === "all", + isHistoryEnabled, ); const { data: myQueueData, @@ -111,6 +113,20 @@ export function useRoomQueuePanel({ (entry: PlaylistEntry) => isEntryRequestedByUser(entry, currentUser), [currentUser], ); + const historyEntries = useMemo( + () => + activeTab === "all" + ? historyQuery.entries + : historyQuery.entries.filter((entry) => + isHistoryEntryRequestedByUser(entry, currentUser), + ), + [activeTab, currentUser, historyQuery.entries], + ); + const visibleCurrentEntry = + activeTab === "all" || + (currentEntry && isCurrentUserEntry(currentEntry)) + ? currentEntry + : null; const canDeleteEntry = (entry: PlaylistEntry) => isPendingQueueEntry(entry) && isCurrentUserEntry(entry); const canDeleteEntryAsOwner = (entry: PlaylistEntry) => @@ -118,7 +134,8 @@ export function useRoomQueuePanel({ let emptyMessage = "플레이리스트가 아직 비어 있습니다."; const isEmptyLoading = - activeTab === "mine" && (isCurrentUserLoading || isMyQueueLoading); + activeTab === "mine" && + (isCurrentUserLoading || isMyQueueLoading || historyQuery.isLoading); if (activeTab === "mine") { if (!isEmptyLoading && !currentUser) { emptyMessage = "내 노래를 확인할 수 없습니다."; @@ -241,7 +258,7 @@ export function useRoomQueuePanel({ allPendingCount, canDeleteEntry, canDeleteEntryAsOwner, - currentEntry, + currentEntry: visibleCurrentEntry, deleteMyQueueEntry, deleteRoomQueueEntries, emptyMessage, @@ -249,42 +266,54 @@ export function useRoomQueuePanel({ handleDeleteRoomEntry, handleMoveMyEntry, handleMoveRoomEntry, - hasNextHistoryPage: historyQuery.hasNextPage, + hasNextHistoryPage: + isHistoryEnabled && Boolean(historyQuery.hasNextPage), hasNextAllQueuePage: allQueueQuery.hasNextPage, hasNextMyQueuePage, - historyEntries: historyQuery.entries, - historyErrorMessage: getQueueErrorMessage(historyQuery.error), - includesLatestHistoryPage: historyQuery.includesLatestPage, + historyEntries, + historyErrorMessage: + activeTab === "mine" && !currentUser + ? "" + : getQueueErrorMessage(historyQuery.error), + includesLatestHistoryPage: + !isHistoryEnabled || historyQuery.includesLatestPage, isEmptyLoading, isCurrentUserEntry, isOwner, - isFetchingNextHistoryPage: historyQuery.isFetchingNextPage, + isFetchingNextHistoryPage: + isHistoryEnabled && historyQuery.isFetchingNextPage, isFetchingNextAllQueuePage: allQueueQuery.isFetchingNextPage, isFetchingNextMyQueuePage, - isHistoryLoading: historyQuery.isLoading, + isHistoryLoading: isHistoryEnabled && historyQuery.isLoading, isQueueLoading: activeTab === "all" ? allQueueQuery.isLoading : isMyQueueLoading, isRefetching: (allQueueQuery.isRefetching && !allQueueQuery.isFetchingNextPage) || - (historyQuery.isRefetching && !historyQuery.isFetchingNextPage) || + (isHistoryEnabled && + historyQuery.isRefetching && + !historyQuery.isFetchingNextPage) || (isMyRefetching && !isFetchingNextMyQueuePage), moveMyQueueEntry, moveRoomQueueEntry, myEntries, myPendingCount, queueErrorMessage, - loadNextHistoryPage: () => historyQuery.fetchNextPage(), + loadNextHistoryPage: () => + isHistoryEnabled ? historyQuery.fetchNextPage() : undefined, loadNextAllQueuePage: () => { return allQueueQuery.fetchNextQueuePage(); }, loadNextMyQueuePage: () => { return fetchNextMyQueuePage(); }, - resetHistoryToLatestPage: historyQuery.resetToLatestPage, + resetHistoryToLatestPage: () => + isHistoryEnabled ? historyQuery.resetToLatestPage() : undefined, retryHistory: () => - historyQuery.isFetchNextPageError - ? historyQuery.fetchNextPage() - : historyQuery.refetch(), + isHistoryEnabled + ? historyQuery.isFetchNextPageError + ? historyQuery.fetchNextPage() + : historyQuery.refetch() + : undefined, retryQueue: () => { if (activeTab === "all") { return allQueueQuery.isFetchNextPageError diff --git a/src/features/room/queue/model/roomQueue.test.ts b/src/features/room/queue/model/roomQueue.test.ts index 4c5c5ad7..e295337a 100644 --- a/src/features/room/queue/model/roomQueue.test.ts +++ b/src/features/room/queue/model/roomQueue.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { PlaylistEntry } from "@/src/features/playlist/model/types"; +import type { + PlaylistEntry, + RoomQueueHistoryEntry, +} from "@/src/features/playlist/model/types"; import { getPendingPersonalQueueEntryIds, isEntryRequestedByUser, + isHistoryEntryRequestedByUser, isValidPersonalQueueMove, mergeCurrentEntryWithQueue, } from "./roomQueue"; @@ -32,6 +36,25 @@ const entry = ( updatedAtMs: 1, }); +const historyEntry = ( + addedByUserSlug: string | null, +): RoomQueueHistoryEntry => ({ + addedByUserSlug, + durationMs: 1, + endedAtMs: 3, + entryId: "history-entry", + id: 1, + provider: "YOUTUBE", + queuedAtMs: 1, + skipped: false, + playbackOrigin: "USER_REQUESTED", + startOffsetMs: 0, + startedAtMs: 2, + thumbnailUrl: null, + title: "지난 곡", + videoId: "history-video", +}); + describe("개인 큐 순서와 공개 식별", () => { it("ownerOrdered 여부와 무관하게 모든 대기곡을 개인 순서 payload에 포함한다", () => { const ids = getPendingPersonalQueueEntryIds([ @@ -58,6 +81,15 @@ describe("개인 큐 순서와 공개 식별", () => { }; expect(isEntryRequestedByUser(entry("mine", false), me)).toBe(true); expect(isEntryRequestedByUser(entry("guest", false, null), me)).toBe(false); + expect(isHistoryEntryRequestedByUser(historyEntry("me"), me)).toBe(true); + expect( + isHistoryEntryRequestedByUser( + { ...historyEntry("me"), playbackOrigin: "AUTOMATIC_REPLAY" }, + me, + ), + ).toBe(false); + expect(isHistoryEntryRequestedByUser(historyEntry("other"), me)).toBe(false); + expect(isHistoryEntryRequestedByUser(historyEntry(null), me)).toBe(false); }); it("현재 곡을 전체 queue 앞에 active로 합치고 중복 entry를 제거한다", () => { diff --git a/src/features/room/queue/model/roomQueue.ts b/src/features/room/queue/model/roomQueue.ts index e4ce023a..e4b8cb0b 100644 --- a/src/features/room/queue/model/roomQueue.ts +++ b/src/features/room/queue/model/roomQueue.ts @@ -1,4 +1,7 @@ -import type { PlaylistEntry } from "@/src/features/playlist/model/types"; +import type { + PlaylistEntry, + RoomQueueHistoryEntry, +} from "@/src/features/playlist/model/types"; import type { User } from "@/src/features/user/model/types"; export type QueueTab = "all" | "mine"; @@ -67,3 +70,19 @@ export function isEntryRequestedByUser( const requesterSlug = entry.addedBy.slug?.trim(); return Boolean(requesterSlug && requesterSlug === currentUser.slug); } + +export function isHistoryEntryRequestedByUser( + entry: RoomQueueHistoryEntry, + currentUser: User | null | undefined, +) { + if (!currentUser) { + return false; + } + + const requesterSlug = entry.addedByUserSlug?.trim(); + return Boolean( + entry.playbackOrigin === "USER_REQUESTED" && + requesterSlug && + requesterSlug === currentUser.slug, + ); +} diff --git a/src/features/room/queue/ui/RoomQueueCard.module.css b/src/features/room/queue/ui/RoomQueueCard.module.css index 927c5484..86680552 100644 --- a/src/features/room/queue/ui/RoomQueueCard.module.css +++ b/src/features/room/queue/ui/RoomQueueCard.module.css @@ -28,6 +28,11 @@ background: rgb(59 130 246 / 8%); } +.item[data-history="true"], +.item[data-history="true"]:hover { + background: #f7f7f9; +} + .item[data-drag-disabled="false"]:hover { cursor: move; } diff --git a/src/features/room/queue/ui/RoomQueueHistoryCard.tsx b/src/features/room/queue/ui/RoomQueueHistoryCard.tsx index a4971e10..d2a7ed30 100644 --- a/src/features/room/queue/ui/RoomQueueHistoryCard.tsx +++ b/src/features/room/queue/ui/RoomQueueHistoryCard.tsx @@ -22,6 +22,7 @@ export default function RoomQueueHistoryCard({ entry }: Props) { return (
  • ({ provider: "YOUTUBE", queuedAtMs: 1, skipped: true, - source: "AUTOMATIC_REPLAY", + playbackOrigin: "AUTOMATIC_REPLAY", + startOffsetMs: 0, startedAtMs: 2, thumbnailUrl: null, title: `지난 곡 ${id}`, @@ -159,6 +160,41 @@ describe("RoomQueueListSection move lock", () => { expect( screen.queryByLabelText("지난 곡 1 순서 변경"), ).not.toBeInTheDocument(); + expect(screen.getByText("지난 곡 1").closest("li")).toHaveAttribute( + "data-history", + "true", + ); + }); + + it("내 노래 탭도 지난 곡과 본인 현재곡을 대기곡 앞에 표시한다", () => { + render( + , + ); + + expect(screen.getByText("지난 곡 1")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "현재 재생 중" })) + .toBeInTheDocument(); + expect(screen.getByLabelText("mine-a 순서 변경")) + .toBeInTheDocument(); + }); + + it("전체 대기곡이 없어도 내 대기곡 목록은 빈 상태로 가리지 않는다", () => { + render( + , + ); + + expect(screen.getByLabelText("mine-a 순서 변경")) + .toBeInTheDocument(); + expect(screen.queryByText("비었음")).not.toBeInTheDocument(); }); it("최신 history가 밀려난 경계에서 현재 곡 복귀 동작을 제공한다", () => { diff --git a/src/features/room/queue/ui/RoomQueueListSection.tsx b/src/features/room/queue/ui/RoomQueueListSection.tsx index 73f1858d..e1348f3b 100644 --- a/src/features/room/queue/ui/RoomQueueListSection.tsx +++ b/src/features/room/queue/ui/RoomQueueListSection.tsx @@ -30,7 +30,7 @@ type RoomQueueListSectionProps = { emptyMessage: string; isDeleteMyPending: boolean; isDeleteRoomPending: boolean; - isAllTimelineLoading?: boolean; + isTimelineLoading?: boolean; isEmptyLoading: boolean; isCurrentUserEntry: (entry: PlaylistEntry) => boolean; isMoveMyPending: boolean; @@ -58,7 +58,7 @@ export default function RoomQueueListSection({ emptyMessage, isDeleteMyPending, isDeleteRoomPending, - isAllTimelineLoading = false, + isTimelineLoading = false, isEmptyLoading, isCurrentUserEntry, isMoveMyPending, @@ -83,37 +83,34 @@ export default function RoomQueueListSection({ emptyMessage ); - if (activeTab === "mine") { - return ( - - ); - } - const activeCurrentEntry = currentEntry ? mergeCurrentEntryWithQueue(currentEntry, [])[0] : null; + const pendingEntries = activeTab === "mine" ? myEntries : allEntries; const hasTimelineEntries = historyEntries.length > 0 || Boolean(activeCurrentEntry) || - allEntries.length > 0; + pendingEntries.length > 0; if (!hasTimelineEntries) { - return isAllTimelineLoading ? null : ( -
    {emptyMessage}
    + return isTimelineLoading ? null : ( +
    {emptyContent}
    ); } - const pendingQueue = isOwner ? ( + const pendingQueue = activeTab === "mine" ? ( + + ) : isOwner ? ( ) : null} - {allEntries.length > 0 ? pendingQueue : null} + {pendingEntries.length > 0 ? pendingQueue : null} ); } diff --git a/src/features/room/queue/ui/RoomQueuePanelView.test.tsx b/src/features/room/queue/ui/RoomQueuePanelView.test.tsx index 33fda9f8..9a2ecafe 100644 --- a/src/features/room/queue/ui/RoomQueuePanelView.test.tsx +++ b/src/features/room/queue/ui/RoomQueuePanelView.test.tsx @@ -113,6 +113,22 @@ describe("RoomQueuePanelView", () => { expect(screen.getByText("대기곡 실패")).toBeInTheDocument(); }); + it("내 노래 탭에서도 지난 곡 조회 실패를 상단 재시도로 복구한다", () => { + const onRetryHistory = vi.fn(); + renderView({ + activeTab: "mine", + historyErrorMessage: "내 지난 곡 실패", + onRetryHistory, + }); + + fireEvent.click( + screen.getByRole("button", { name: "지난 곡 다시 시도" }), + ); + + expect(onRetryHistory).toHaveBeenCalledOnce(); + expect(screen.getByText("내 지난 곡 실패")).toBeInTheDocument(); + }); + it("초기·추가 조회 loading을 기존 목록 영역의 방향별 상태로 표시한다", () => { renderView({ isFetchingNextAllQueuePage: true, diff --git a/src/features/room/queue/ui/RoomQueuePanelView.tsx b/src/features/room/queue/ui/RoomQueuePanelView.tsx index ac6befd7..a163d206 100644 --- a/src/features/room/queue/ui/RoomQueuePanelView.tsx +++ b/src/features/room/queue/ui/RoomQueuePanelView.tsx @@ -137,6 +137,7 @@ export default function RoomQueuePanelView({ handleReturnToCurrent, handleRetryHistory, handleScroll, + handleWheel, scrollContainerRef, } = useQueueBidirectionalScroll({ activeTab, @@ -171,8 +172,9 @@ export default function RoomQueuePanelView({ aria-label="재생목록" tabIndex={0} onScroll={handleScroll} + onWheel={handleWheel} > - {activeTab === "all" && historyErrorMessage ? ( + {historyErrorMessage ? (
    {historyErrorMessage}
    - ) : activeTab === "all" && - (isHistoryLoading || isFetchingNextHistoryPage) ? ( + ) : isHistoryLoading || isFetchingNextHistoryPage ? (
    @@ -200,7 +201,7 @@ export default function RoomQueuePanelView({ isCurrentUserEntry={isCurrentUserEntry} isDeleteMyPending={isDeleteMyPending} isDeleteRoomPending={isDeleteRoomPending} - isAllTimelineLoading={isHistoryLoading || isQueueLoading} + isTimelineLoading={isHistoryLoading || isQueueLoading} isMoveMyPending={isMoveMyPending} isMoveRoomPending={isMoveRoomPending} isOwner={isOwner} @@ -241,16 +242,12 @@ export default function RoomQueuePanelView({ /> ) : null} - {activeTab === "all" ? ( - <> -