refactor(space): scene 도메인과 current session 계약을 정리

This commit is contained in:
2026-03-09 13:05:44 +09:00
parent 8184915cb1
commit 675014166a
19 changed files with 243 additions and 208 deletions

View File

@@ -6,7 +6,7 @@ export type FocusSessionCompletionType = 'goal-complete' | 'timer-complete';
export interface FocusSession {
id: string;
roomId: string;
sceneId: string;
goal: string;
timerPresetId: string;
soundPresetId: string | null;
@@ -24,7 +24,7 @@ export interface FocusSession {
}
export interface StartFocusSessionRequest {
roomId: string;
sceneId: string;
goal: string;
timerPresetId: string;
soundPresetId?: string | null;
@@ -53,7 +53,7 @@ export const focusSessionApi = {
/**
* Backend Codex:
* - 새로운 집중 세션을 생성하고 즉시 running 상태로 시작한다.
* - roomId, goal, timerPresetId, soundPresetId를 저장한다.
* - sceneId, goal, timerPresetId, soundPresetId를 저장한다.
* - 응답에는 생성 직후의 세션 상태와 남은 시간 계산에 필요한 시각 필드를 포함한다.
*/
startSession: async (payload: StartFocusSessionRequest): Promise<FocusSession> => {
@@ -66,11 +66,12 @@ export const focusSessionApi = {
/**
* Backend Codex:
* - 현재 세션의 현재 phase를 일시정지한다.
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
* - phaseRemainingSeconds를 정확히 저장해 재개 시 이어서 동작하게 한다.
* - 이미 paused 상태여도 멱등적으로 최신 세션 상태를 반환한다.
*/
pauseSession: async (sessionId: string): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/pause`, {
pauseSession: async (): Promise<FocusSession> => {
return apiClient<FocusSession>('api/v1/focus-sessions/current/pause', {
method: 'POST',
});
},
@@ -78,11 +79,12 @@ export const focusSessionApi = {
/**
* Backend Codex:
* - 일시정지된 세션을 재개하고 새 phaseEndsAt/serverNow를 반환한다.
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
* - 이미 running 상태여도 멱등적으로 최신 세션 상태를 반환한다.
* - 남은 시간을 다시 계산할 수 있게 phaseRemainingSeconds도 함께 내려준다.
*/
resumeSession: async (sessionId: string): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/resume`, {
resumeSession: async (): Promise<FocusSession> => {
return apiClient<FocusSession>('api/v1/focus-sessions/current/resume', {
method: 'POST',
});
},
@@ -90,11 +92,12 @@ export const focusSessionApi = {
/**
* Backend Codex:
* - 현재 진행 중인 phase를 처음 길이로 다시 시작한다.
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
* - focus/break 어느 phase인지 유지한 채 phaseStartedAt, phaseEndsAt, phaseRemainingSeconds를 갱신한다.
* - 클라이언트의 Reset 버튼은 이 API 응답으로 즉시 HUD를 다시 그린다.
*/
restartCurrentPhase: async (sessionId: string): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/restart-phase`, {
restartCurrentPhase: async (): Promise<FocusSession> => {
return apiClient<FocusSession>('api/v1/focus-sessions/current/restart-phase', {
method: 'POST',
});
},
@@ -102,14 +105,12 @@ export const focusSessionApi = {
/**
* Backend Codex:
* - 현재 세션을 완료 처리하고 통계 집계 대상으로 반영한다.
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
* - completionType으로 goal-complete / timer-complete을 구분해 저장한다.
* - 완료된 세션 스냅샷을 반환하거나, 최소한 성공적으로 완료되었음을 알 수 있는 응답을 보낸다.
*/
completeSession: async (
sessionId: string,
payload: CompleteFocusSessionRequest,
): Promise<FocusSession> => {
return apiClient<FocusSession>(`api/v1/focus-sessions/${sessionId}/complete`, {
completeSession: async (payload: CompleteFocusSessionRequest): Promise<FocusSession> => {
return apiClient<FocusSession>('api/v1/focus-sessions/current/complete', {
method: 'POST',
body: JSON.stringify(payload),
});
@@ -118,11 +119,12 @@ export const focusSessionApi = {
/**
* Backend Codex:
* - 현재 세션을 중도 종료(abandon) 처리한다.
* - 클라이언트는 sessionId 대신 access token으로 사용자의 current session을 식별한다.
* - 통계에서는 abandon 여부를 구분할 수 있게 저장한다.
* - 성공 시 204 또는 빈 성공 응답을 반환한다.
*/
abandonSession: async (sessionId: string): Promise<void> => {
return apiClient<void>(`api/v1/focus-sessions/${sessionId}/abandon`, {
abandonSession: async (): Promise<void> => {
return apiClient<void>('api/v1/focus-sessions/current/abandon', {
method: 'POST',
expectNoContent: true,
});

View File

@@ -199,7 +199,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
}
const session = await runMutation(
() => focusSessionApi.pauseSession(currentSession.id),
() => focusSessionApi.pauseSession(),
'세션을 일시정지하지 못했어요.',
);
@@ -211,7 +211,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
}
const session = await runMutation(
() => focusSessionApi.resumeSession(currentSession.id),
() => focusSessionApi.resumeSession(),
'세션을 다시 시작하지 못했어요.',
);
@@ -223,7 +223,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
}
const session = await runMutation(
() => focusSessionApi.restartCurrentPhase(currentSession.id),
() => focusSessionApi.restartCurrentPhase(),
'현재 페이즈를 다시 시작하지 못했어요.',
);
@@ -235,7 +235,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
}
const session = await runMutation(
() => focusSessionApi.completeSession(currentSession.id, payload),
() => focusSessionApi.completeSession(payload),
'세션을 완료 처리하지 못했어요.',
);
@@ -251,7 +251,7 @@ export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
}
const result = await runMutation(
() => focusSessionApi.abandonSession(currentSession.id),
() => focusSessionApi.abandonSession(),
'세션을 종료하지 못했어요.',
);

View File

@@ -38,7 +38,7 @@ export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, classN
{thought.text}
</p>
<p className="mt-1.5 text-[11px] text-white/54">
{thought.roomName} · {thought.capturedAt}
{thought.sceneName} · {thought.capturedAt}
</p>
<div className="absolute right-2 top-2 flex items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100">
<button

View File

@@ -0,0 +1 @@
export * from './ui/SceneSelectCarousel';

View File

@@ -1,36 +1,36 @@
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
import { getSceneCardBackgroundStyle, type SceneTheme } from '@/entities/scene';
import { cn } from '@/shared/lib/cn';
interface SpaceSelectCarouselProps {
rooms: RoomTheme[];
selectedRoomId: string;
onSelect: (roomId: string) => void;
interface SceneSelectCarouselProps {
scenes: SceneTheme[];
selectedSceneId: string;
onSelect: (sceneId: string) => void;
}
export const SpaceSelectCarousel = ({
rooms,
selectedRoomId,
export const SceneSelectCarousel = ({
scenes,
selectedSceneId,
onSelect,
}: SpaceSelectCarouselProps) => {
}: SceneSelectCarouselProps) => {
return (
<div className="-mx-1 overflow-x-auto px-1 pb-1">
<div className="flex min-w-full gap-2.5">
{rooms.map((room) => {
const selected = room.id === selectedRoomId;
{scenes.map((scene) => {
const selected = scene.id === selectedSceneId;
return (
<button
key={room.id}
key={scene.id}
type="button"
onClick={() => onSelect(room.id)}
onClick={() => onSelect(scene.id)}
className={cn(
'group relative h-24 min-w-[138px] overflow-hidden rounded-xl border text-left sm:min-w-[148px]',
selected
? 'border-sky-200/38 shadow-[0_0_0_1px_rgba(186,230,253,0.2),0_0_10px_rgba(56,189,248,0.12)]'
: 'border-white/16 hover:border-white/24',
)}
style={getRoomCardBackgroundStyle(room)}
aria-label={`${room.name} 선택`}
style={getSceneCardBackgroundStyle(scene)}
aria-label={`${scene.name} 선택`}
>
<span className="absolute inset-x-0 bottom-0 h-[56%] bg-[linear-gradient(180deg,rgba(2,6,23,0)_0%,rgba(2,6,23,0.2)_52%,rgba(2,6,23,0.24)_100%)]" />
{selected ? (
@@ -39,8 +39,8 @@ export const SpaceSelectCarousel = ({
</span>
) : null}
<div className="absolute inset-x-2 bottom-2">
<p className="truncate text-sm font-semibold text-white/96">{room.name}</p>
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
<p className="truncate text-sm font-semibold text-white/96">{scene.name}</p>
<p className="truncate text-[11px] text-white/66">{scene.vibeLabel}</p>
</div>
</button>
);

View File

@@ -1 +0,0 @@
export * from './ui/SpaceSelectCarousel';