Compare commits
91 Commits
2ac568a4ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6df34a0eb7 | |||
| 9b013f1843 | |||
| 14d7165ffe | |||
| d193973eb5 | |||
| f580ab8a72 | |||
| 167e64534f | |||
| 3a06881634 | |||
| bdbcf3c3f1 | |||
| 99146fb08b | |||
| 3204609f3d | |||
| fd1b7123e2 | |||
| cd91ff9ae5 | |||
| aff1a007b2 | |||
| f21129fc5d | |||
| 13a94ef42d | |||
| 2afbe3ce7a | |||
| 5026138ad9 | |||
| 4bbee36e1e | |||
| 1d2ce85cfd | |||
| 6194c19f3b | |||
| abc1525fe2 | |||
| 38abc1e0c7 | |||
| f4910238a0 | |||
| ec941f3cde | |||
| 627bd82706 | |||
| b91fdbcb67 | |||
| fb2729193f | |||
| c63ddc4e98 | |||
| 6b25a18d5a | |||
| e16a182499 | |||
| 8f4a69fc77 | |||
| 81e969c116 | |||
| 16d620ee4a | |||
| 721212ec1f | |||
| c6e342e93d | |||
| 38a9d1e762 | |||
| acfa8f4f48 | |||
| 3471c96972 | |||
| 56385ec2eb | |||
| 3c5154178d | |||
| 728330bf74 | |||
| 3aba789c97 | |||
| 1b01ceaa8b | |||
| 6b70d07e3c | |||
| 6a0710d023 | |||
| cbeeb38413 | |||
| 0f01ecd8a1 | |||
| b3853c98d2 | |||
| 6bf3336aec | |||
| de95505d2f | |||
| 5d3a5ac8ac | |||
| c8b00905cd | |||
| fe908ec415 | |||
| 445ef54528 | |||
| dc97a78fdd | |||
| 679601d201 | |||
| 74e44fff69 | |||
| 0b8c207fe2 | |||
| 729afe0cbf | |||
| 278fc11135 | |||
| b0fe2887c6 | |||
| 425943cf89 | |||
| 9abe868db6 | |||
| caf53f0b68 | |||
| cc3eafb2fa | |||
| 4421e776b2 | |||
| fe67597320 | |||
| a27cce9a67 | |||
| b4ed94cf1b | |||
| bc08a049b6 | |||
| 6154bd54a8 | |||
| a1424a4794 | |||
| 88bb4f40b8 | |||
| abdde2a8ae | |||
| 2506dd53a7 | |||
| 698c124ade | |||
| 972be117cb | |||
| 35f1dfb92d | |||
| 7867bd39ca | |||
| 4717bb3a1a | |||
| 9811134d8a | |||
| c47f60163d | |||
| 1717f335f0 | |||
| 92a509ebb6 | |||
| 1c55f74132 | |||
| 986b9ba94b | |||
| cceaa6bd82 | |||
| 675014166a | |||
| 8184915cb1 | |||
| d18d9b2bb9 | |||
| 09b02f4168 |
@@ -1,22 +1,202 @@
|
||||
# 90. Current State
|
||||
|
||||
Last Updated: 2026-03-05
|
||||
Last Updated: 2026-03-16
|
||||
|
||||
## DONE
|
||||
|
||||
- `/app` session gate 제거:
|
||||
- `/app`은 더 이상 running / paused / takeover UI를 보여주지 않는다
|
||||
- current session이 있으면 상태와 상관없이 즉시 `/space`로 이동한다
|
||||
- no-session일 때만 atmosphere entry shell이 열린다
|
||||
- `/app` Atmosphere Entry Shell 1차 구현:
|
||||
- no-session `/app`을 `goal + duration + atmosphere` 중심의 premium entry shell로 교체했다
|
||||
- `microStep` 입력은 entry에서 제거했고, `예상 시간(분)` 입력과 12개 dummy atmosphere grid를 추가했다
|
||||
- atmosphere는 `scene + sound`가 함께 묶인 선택 단위로 동작하며, 선택한 atmosphere가 `/app` 배경과 `/space` start payload에 같이 반영된다
|
||||
- 입력한 duration은 raw `focusDurationMinutes`로 server에 전달된다
|
||||
- weekly review entry는 main CTA를 먹지 않도록 no-session shell의 quiet secondary dock 위치로 이동했다
|
||||
- `/app` Atmosphere Entry Shell visual premium polish:
|
||||
- no-session shell을 `decision rail + selected atmosphere stage + curated atmosphere library` 구조로 다시 짰다
|
||||
- 좌측은 입력과 시작 결정을 담당하고, 우측은 선택한 atmosphere의 immersive preview를 크게 보여준다
|
||||
- review entry는 start stage 아래 quiet dock로 내려 main CTA와 경쟁하지 않게 정리했다
|
||||
|
||||
- current session direct start 차단:
|
||||
- silent abandon을 막기 위해 server `startSession()`은 current session 존재 시 direct start를 거절한다
|
||||
- `/app` 기존 single-goal commitment gate는 legacy로 내려갔다:
|
||||
- 2-step `goal -> ritual` flow를 제거하고, current session이 있으면 `Resume` UI를 우선 노출하도록 정리했다
|
||||
- 현재 source-of-truth는 `goal + duration + atmosphere` 중심의 새 entry shell spec이다
|
||||
- `/space` Refocus System slice 1 구현:
|
||||
- HUD recovery layer를 `paused / refocus / next-beat / complete` 단일 overlay 상태로 정리
|
||||
- pause 직후 바로 편집 시트를 열지 않고, 작은 recovery prompt를 먼저 노출
|
||||
- `한 조각 다시 잡기`로 refocus에 들어가고, paused 상태에서는 `적용하고 이어가기`로 바로 resume 연결
|
||||
- microStep 완료 후 `다음 한 조각이 있나요?` next-beat prompt로만 이어지게 정리
|
||||
- 한 번에 하나의 recovery tray만 열리도록 hierarchy를 고정
|
||||
- `/space` Refocus System slice 2 구현:
|
||||
- pause prompt의 `이대로 이어가기`가 실제 resume 동작으로 연결
|
||||
- goal complete tray에 `여기서 마무리하기` 경로 추가
|
||||
- 현재 세션을 다음 목표 입력 없이도 정상 완료 처리할 수 있게 연결
|
||||
- goal complete / rest / next-goal의 세 분기가 UI와 동작 모두에서 분리됨
|
||||
- `/space` Refocus System slice 3 구현:
|
||||
- goal complete tray가 초기부터 input form을 강요하지 않도록 progressive disclosure 구조로 변경
|
||||
- `여기서 마무리하기 / 잠시 비우기 / 다음 목표 이어가기`를 먼저 제안하고, 다음 목표 입력은 선택 시에만 펼쳐지게 정리
|
||||
- next-beat prompt에 현재 goal 문맥을 함께 보여주도록 보강
|
||||
- `/space` Refocus System slice 4 구현:
|
||||
- pause / next-beat / complete / refocus tray의 glass material, hairline, spacing을 공통 규칙으로 정리
|
||||
- 선택 액션을 단순 inline 링크에서 quiet option row로 바꿔 recovery decision hierarchy를 선명하게 만듦
|
||||
- `goal complete`의 세 분기를 같은 tray 안의 선택 행으로 통합해 planner/form 느낌을 약화
|
||||
- `refocus` form field와 footer action 톤을 다른 recovery tray와 같은 제품군으로 맞춤
|
||||
- `/space` Away / Return Recovery slice 구현:
|
||||
- `visibilitychange`, `pagehide`, sleep/wake gap 기반 AwayCandidate 감지 추가
|
||||
- 짧은 탭 전환에는 반응하지 않도록 hidden threshold를 둠
|
||||
- 돌아왔을 때 focus가 아직 running이면 `Return` tray에서 `이어서 하기 / 한 조각 다시 잡기`를 제안
|
||||
- 자리를 비운 사이 focus가 끝나 break phase가 되었으면 standard break 대신 `Return` tray를 먼저 띄움
|
||||
- 이 경우 `쉬기 이어가기 / 다음 목표 이어가기 / 한 조각 다시 잡기`를 선택할 수 있음
|
||||
- `다음 목표 이어가기`는 `Goal Complete` next view로 바로 연결됨
|
||||
- `/space` Pause tray premium polish:
|
||||
- tray 폭과 열림 높이를 키워 긴 한국어 카피가 잘리지 않게 조정
|
||||
- eyebrow / title / body의 typography hierarchy와 line-height를 재정렬
|
||||
- option row spacing, radius, chevron 위치를 보정해 급조된 버튼 묶음 느낌을 완화
|
||||
- `/space` Pause / Break / Return tone 분리 1차 구현:
|
||||
- `Return(focus)`와 `Return(break)`가 같은 tray처럼 보이지 않도록 break tray에 emerald tint release tone 도입
|
||||
- `Goal Complete`의 `잠시 비우기` 선택도 같은 break 계열 material로 연결
|
||||
- timer HUD는 break phase에서 더 가벼운 emerald 계열 glass로 보정해 focus/pause와 구분되게 조정
|
||||
- `/space` Pause / Break / Return copy + interaction polish:
|
||||
- `Pause`는 `멈춘 이유` 대신 `다시 시작할 한 줄`을 중심으로 카피를 다시 정리
|
||||
- `Return(focus)`는 `멈춘 자리에서 이어가기`, `Return(break)`는 `쉬기 이어가기 / 다음 블록 이어가기` 중심으로 재서술
|
||||
- `Goal Complete`는 `다음 블록 이어가기 / 잠시 비우기 / 여기서 마무리하기` 순의 선택 tray를 먼저 보여주고, 다음 블록 입력은 이후 단계에서만 열리게 정리
|
||||
- choice/next view의 헤더와 설명도 각각 다른 감정 상태에 맞춰 분리
|
||||
- `/space` Pause / Break / Return motion polish 1차 구현:
|
||||
- `Pause` tray는 빠르게 다시 붙잡는 recovery reveal로 조정
|
||||
- `Return(focus)`는 짧은 re-entry settle motion으로, `Return(break)`는 더 느슨한 release reveal로 분리
|
||||
- `Goal Complete`도 같은 recovery family 안에서 가장 느린 closure motion을 가지도록 조정
|
||||
- `/space` goal closure CTA matrix 정렬:
|
||||
- `break` phase에서도 expanded intent card 안에 `이번 목표 완료`를 유지하도록 수정
|
||||
- base card가 잠기는 recovery overlay(`pause / return / next-beat`) 안에서도 low-emphasis `여기서 마무리하기` 경로를 추가
|
||||
- 이제 active session 상태에서는 `계속 / 다시 잡기 / 마무리` 중 최소 한 경로가 항상 보이도록 정리
|
||||
- `/space` timer completion flow 재정의:
|
||||
- focus timer가 끝나면 더 이상 break로 자동 반복되지 않는다
|
||||
- timer가 `00:00`에 도달하면 same-session modal이 자동으로 열리고, `완료하고 종료하기 / 10분 더` 두 경로만 제안한다
|
||||
- `10분 더`를 누르면 현재 focus phase에 10분이 추가되어 바로 running으로 돌아가고, 다시 시간이 끝나면 같은 modal이 다시 열린다
|
||||
- server와 web 모두 `extend-phase` 계약 기준으로 동작한다
|
||||
- `10분 더`는 남은 시간만이 아니라 `focusDurationSeconds`도 함께 늘려 이후 결과 모달과 review의 집중 시간이 맞도록 정리됐다
|
||||
- `/space` completion result modal 추가:
|
||||
- `timer-complete -> 완료하고 종료하기`와 `End Session -> 여기서 마무리하기`는 즉시 `/app`으로 가지 않고 중앙 결과 모달을 먼저 연다
|
||||
- 결과 모달에는 `집중한 시간`, `완료한 목표`, `이번 세션 thought capsule`이 표시된다
|
||||
- 결과 모달이 떠 있는 동안 `/space` 자동 `/app` redirect는 막히고, `확인하고 돌아가기`에서만 `/app`으로 이동한다
|
||||
- current-session thought capsule 서버 복원:
|
||||
- `/space` thought는 public `focusSessionId` 없이 auth 기반으로 현재 세션에 서버가 내부 연결한다
|
||||
- `/space` 진입 시 `GET /api/v1/focus-sessions/current/thoughts`로 현재 세션 thought 목록을 복원한다
|
||||
- 브라우저를 껐다 켜도 current session이 살아 있으면 같은 thought 목록을 다시 읽을 수 있다
|
||||
- `/space` intent HUD collapsed / expanded 재설계:
|
||||
- 상시 큰 goal 카드 대신 idle에서는 goal 1줄만 남는 collapsed glass rail 구조로 변경
|
||||
- hover / focus / rail tap에서만 expanded card로 열리며, 이때만 microStep과 `이번 목표 완료` 액션이 노출됨
|
||||
- recovery tray(`pause / return / next-beat / complete / refocus`)가 열릴 때는 base card가 강제로 collapsed 상태를 유지하도록 정리
|
||||
- expanded rail은 outside click으로 접히지만, recovery tray는 outside click으로 닫히지 않고 명시적 액션으로만 닫힘
|
||||
- rail 클릭은 expand/collapse만 담당하고, refocus는 expanded 상태의 `수정` 액션으로만 진입
|
||||
|
||||
- Focus Entry Surface / Execution Surface 재정의:
|
||||
- `/app`을 planning home이 아니라 hero-first focus entry surface로 재구성
|
||||
- 상단 카피를 `Planning Home` 톤에서 `지금 시작할 첫 블록` 진입 톤으로 교체
|
||||
- 메인 hero에 one-line goal input + 단일 primary CTA `지금 시작`만 남기고, 첫 진입의 주 행동을 고정
|
||||
- empty state에서는 starter draft를 자동 주입하지 않고 placeholder 입력으로 시작한다
|
||||
- suggestion chip으로 draft를 빠르게 교체할 수 있게 하고, 직접 타이핑 시에는 ad-hoc start를 허용
|
||||
- 이후 `/app`은 current session이 있으면 `Resume`, 없으면 single-goal direct start만 남기는 commitment gate로 더 줄였다
|
||||
- block CRUD, preview row, list-first 구조는 메인 진입 경로에서 제거했다
|
||||
- `/space`는 planning overview 없이 goal/scene/sound/timer + HUD 실행 화면으로 정리
|
||||
- focus-plan / focus-session 서버 계약 연결:
|
||||
- `GET /api/v1/focus-plan/today`
|
||||
- `POST /api/v1/focus-plan/items`
|
||||
- `PATCH /api/v1/focus-plan/items/:id`
|
||||
- `DELETE /api/v1/focus-plan/items/:id`
|
||||
- `POST /api/v1/focus-plan/items/:id/complete`
|
||||
- `POST /api/v1/focus-sessions/current/advance-goal`
|
||||
- frontend에서 plan item id와 today plan 응답을 backend contract 기준으로 정규화
|
||||
- `GET /today`는 current 이후 pending item 전체를 `nextItems`로 반환
|
||||
- 목표 완료 후 다음 목표 즉시 실행 흐름 구현:
|
||||
- GoalCompleteSheet에서 다음 목표를 입력하면 현재 세션 완료 + 새 planning item 생성 + 다음 세션 즉시 시작
|
||||
- 같은 scene / sound / timer를 유지한 채 `/space` focus 화면에서 그대로 이어감
|
||||
- 실패 시 시트를 닫지 않고 HUD 토스트로 에러만 노출
|
||||
- `/stats` factual summary 정리:
|
||||
- weekly insight / quiet accountability mock 제거
|
||||
- today / last7Days / trend만 남기고 factual card 구조로 단순화
|
||||
- Calm Session OS 유료화 축 구현:
|
||||
- Free는 기본 시작, Pro는 더 잘 이어가기를 파는 구조로 재정의
|
||||
- old `Scene Packs / Sound Packs / Profiles` 중심 copy를 `Daily Focus Plan / Rituals / Weekly Review` 중심으로 교체
|
||||
- `/app` route를 focus entry surface로 복구:
|
||||
- `/app` route가 `/space` redirect 대신 `FocusDashboardWidget`을 렌더링
|
||||
- current/next summary card와 list-first 구조를 제거했다
|
||||
- 현재는 `Resume` gate가 구현되어 있고, no-session entry shell은 다음 slice에서 `goal + duration + atmosphere` 구조로 교체될 예정이다
|
||||
- 플랜 tier 공유 store 추가:
|
||||
- `entities/plan/model/usePlanTier.ts` 추가
|
||||
- localStorage 기반 Free/Pro 상태를 `/app`, `/space`, `/stats`, dock paywall에서 공통 사용
|
||||
- Session OS 도메인 mock 추가:
|
||||
- `FocusPlanItem`
|
||||
- `SessionTemplate`
|
||||
- `SessionOutcome`
|
||||
- `WeeklyReview`
|
||||
- `AsyncCheckIn`
|
||||
- `entities/session/model/focusSystem.ts`에 mock/헬퍼 집약
|
||||
- `/space` planning overview 제거:
|
||||
- setup drawer에서 Daily Plan / Ritual Library 진입 섹션 제거
|
||||
- `/app`에서 넘긴 goal + `planItemId`를 받아 execution-only surface로 집중
|
||||
- `/stats` factual summary 정착:
|
||||
- factual card 반복 중심의 구조를 해체하고 `Weekly Review` 1차 IA로 전환
|
||||
- `snapshot + start quality + recovery quality + completion quality + carry forward` 구조를 반영
|
||||
- 기존 `focus-summary` 응답을 주간 review view model로 변환해서 사용
|
||||
- recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `자리 비움 뒤 복귀`만 limited note로 남긴다
|
||||
- `/stats` immersive review stage polish:
|
||||
- `/stats`를 밝은 대시보드 카드 반복 화면에서 dark immersive review stage로 재구성했다
|
||||
- 중앙 hero summary, snapshot signal board, start/recovery/completion observatory panel, carry-forward closure stage를 같은 glass family로 통일했다
|
||||
- carry-forward ritual에 맞는 atmosphere 배경을 얇게 투영하되, `/app`의 entry ritual을 복제하지 않고 통계 화면 고유의 observatory 톤으로 분리했다
|
||||
- `/app -> /stats` primary entry의 1차 연결:
|
||||
- current session이 없고 최근 7일 데이터가 충분할 때 `/app`의 quiet secondary review dock에서 `Weekly Review` entry를 노출한다
|
||||
- current session이 있으면 `/app` 자체가 `/space`로 이동하므로, `/app` review entry는 no-session entry shell 안에서만 다룬다
|
||||
- `/stats -> /app` handoff의 2차 연결:
|
||||
- `/stats` 마지막 CTA는 `/app?review=weekly&carryHint=...&entryAtmosphereId=forest-draft&entryDurationMinutes=50`으로 연결된다
|
||||
- `/app`은 이 query를 받아 entry stage 위의 review-aware return hint를 노출한다
|
||||
- goal과 duration은 자동 입력하지 않고, 방향만 가볍게 제안한다
|
||||
- Pro personalized handoff 3차 연결:
|
||||
- Pro에서는 `/stats` carry-forward 섹션에 추천 ritual을 함께 보여준다
|
||||
- `/stats` 마지막 CTA 카피가 generic start가 아니라 `가장 잘 맞은 atmosphere로 /app 돌아가기`로 바뀐다
|
||||
- `/app` teaser와 review return hint도 Pro에서 더 구체적인 next-session handoff 톤으로 표시된다
|
||||
- `Weekly Review` recovery의 서버 연결:
|
||||
- server `focus-summary` 응답에 `recovery`가 추가됐다
|
||||
- `pause_count / resume_count` 기반 `pause 뒤 복귀`를 실제 수치로 보여준다
|
||||
- 현재는 `away recovery` 이벤트 스키마가 없어 partial/limited 상태로 남긴다
|
||||
- paywall / plan / landing 메시지 재정렬:
|
||||
- paywall 가치 포인트를 multi-queue, rituals, weekly review 중심으로 재작성
|
||||
- landing pricing에서 구현되지 않은 1:1 매칭 / 오픈 코워킹 / 팀 대시보드를 메인 판매 포인트에서 제거
|
||||
- Teams는 후순위 준비중 톤으로 약화
|
||||
- Gemini 분리본 재점검:
|
||||
- `SpaceWorkspaceWidget`, `SpaceToolsDockWidget`, `admin/page.tsx`, `shared/i18n/ko.ts` 분리 상태를 다시 확인
|
||||
- 현재 기준 500줄 초과 파일 없음
|
||||
- 최대 파일은 `src/widgets/admin-console/ui/AdminDashboardView.tsx` 482줄
|
||||
- `/admin` 업로드 콘솔 회귀 복구:
|
||||
- widget 분리 이후 로그인 후 placeholder만 보이던 상태 제거
|
||||
- `AdminConsoleWidget`은 조합만 담당하고, 실제 scene/sound 업로드 UI는 `AdminDashboardView`로 분리 복원
|
||||
- 인증 전역 상태 위치 정리:
|
||||
- `src/store/useAuthStore.ts` 제거
|
||||
- `entities/auth/model/useAuthStore.ts`로 이동해 auth feature가 루트 store를 직접 참조하지 않도록 정리
|
||||
- auth 타입 참조 정리:
|
||||
- `features/admin/api/adminApi.ts`가 `features/auth/types` 대신 `entities/auth`를 직접 사용
|
||||
- `features/auth/types/index.ts`는 중복 선언을 제거하고 `entities/auth` 재export만 담당
|
||||
- `/space` stage 배경 overscan 보정:
|
||||
- pan 애니메이션 중 가장자리 빈틈이 드러나지 않도록 stage background layer를 `-inset-8`로 확장
|
||||
- `/space` 배경 asset 해석 안정화:
|
||||
- media manifest scene key를 scene alias까지 정규화해 `green-forest`와 `forest`를 동일 asset으로 해석
|
||||
- scene/sound asset에 `source(fallback|remote)` 메타를 추가해 실제 fallback 사용 여부를 구분 가능하게 정리
|
||||
- remote manifest load 실패 시 error 상태를 노출하고, `/space`에서 manifest 실패/scene fallback 사용을 진단 로그로 남기도록 보강
|
||||
- Focus 피드백 채널 단일화:
|
||||
- HUD 내부 status line을 제거하고 상단 중앙 고정 토스트로 통합
|
||||
- Notes 저장/Undo, Goal 전환, 잠금 안내 피드백이 동일 위치에서 노출
|
||||
- Free 코어 루프 개방:
|
||||
- Quick Controls Time의 `90/20` 잠금을 제거
|
||||
- 기본 Sound 잠금 제거로 Free에서도 기본 3~6 프리셋 선택 가능
|
||||
- Pro 가치 재배치:
|
||||
- Pro 잠금 대상을 `Scene Packs / Sound Packs / Profiles`로 재정의
|
||||
- Pro 잠금 대상을 `Daily Focus Plan / Rituals / Weekly Review`로 재정의
|
||||
- 기본 Scene/Time/Sound는 잠금 없이 선택 중심으로 정리
|
||||
- Control Center UI 재구성:
|
||||
- Scene/Time/Sound 중심 구조 유지
|
||||
- 추천 조합을 정보 1줄로 축소(비인터랙션)
|
||||
- 하단에 Packs/Profiles 요약 카드(작은 🔒 배지) 추가
|
||||
- 하단에 Session OS 요약 카드(작은 🔒 배지) 추가
|
||||
- Paywall 의도 기반 트리거 적용:
|
||||
- 잠금 카드 클릭 시에만 Paywall Sheet 오픈
|
||||
- Plan Pill(NORMAL) 클릭은 즉시 결제창 대신 상태 안내만 표시
|
||||
@@ -34,11 +214,11 @@ Last Updated: 2026-03-05
|
||||
- 모드 설명 1줄(기본: 모든 컨트롤 표시, 몰입: 필수만 남기고 숨김) 추가
|
||||
- 모드 상태를 workspace -> tools-dock -> focus-hud 경로로 연결해 HUD 톤 반영 유지
|
||||
- `/space` Scene 기반 자동 추천 적용:
|
||||
- `RoomTheme`에 `recommendedSoundPresetId`, `recommendedTimerPresetId` 필드 추가
|
||||
- 첫 진입/시작 시 Scene 추천 타이머/사운드가 자동 반영되도록 초기화 로직 정리
|
||||
- Scene 변경 시 `override.sound/timer`가 `false`인 항목만 자동 동기화
|
||||
- `RoomTheme`에 `recommendedSoundPresetId` 필드 추가
|
||||
- 첫 진입/시작 시 Scene 추천 사운드와 atmosphere 기반 duration이 자동 반영되도록 초기화 로직 정리
|
||||
- Scene 변경 시 `override.sound/duration`가 `false`인 항목만 자동 동기화
|
||||
- `/space` 사용자 override 존중 규칙 도입:
|
||||
- `override.sound`, `override.timer` UI 상태 추가
|
||||
- `override.sound`, `override.duration` UI 상태 추가
|
||||
- 사용자가 직접 고른 항목은 이후 Scene 변경에도 자동 덮어쓰기되지 않도록 반영
|
||||
- `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원 지원
|
||||
- `Control Center`를 Scene/Time 중심으로 단순화:
|
||||
@@ -48,18 +228,18 @@ Last Updated: 2026-03-05
|
||||
- 우하단 Sound Quick 경로를 override 적용의 명시적 경로로 분리:
|
||||
- `onQuickSoundSelect` 콜백으로 연결해 `override.sound` 규칙을 코드 레벨에서 고정
|
||||
- 세션 상태 더미 저장/복원 추가:
|
||||
- `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`를 localStorage에 저장
|
||||
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
|
||||
- `sceneId`, `durationMinutes`, `soundPresetId`, `goal`, `override(sound/duration)`를 localStorage에 저장
|
||||
- 복원 우선순위: 저장 상태 > 사용자 기본 설정 > atmosphere 추천
|
||||
- `/space` 진입 Resume CTA 추가:
|
||||
- 저장된 목표가 있고 쿼리 오버라이드가 없을 때 `지난 한 조각 이어서` 블록 1회 노출
|
||||
- `이어서 시작`: 저장 목표로 즉시 Focus 진입
|
||||
- `새로 시작`: 목표를 비워 새 세션 입력 흐름으로 전환
|
||||
- 세션 복구 운영 문서 추가:
|
||||
- `docs/06_commit_convention.md`
|
||||
- `docs/07_session_recovery.md`
|
||||
- `docs/foundation/06_commit_convention.md`
|
||||
- `docs/ops/07_session_recovery.md`
|
||||
- 워크플로우 토큰 절약 모드 추가:
|
||||
- `docs/context_core.md` 신설
|
||||
- `docs/workFlow.md`를 기본 3문서 + 조건부 로드로 변경
|
||||
- `docs/ops/workFlow.md`를 기본 3문서 + 조건부 로드로 변경
|
||||
- 워크플로우 기본 로드를 2파일로 축소:
|
||||
- `docs/work.md`
|
||||
- `docs/session_brief.md`
|
||||
@@ -153,12 +333,22 @@ Last Updated: 2026-03-05
|
||||
|
||||
## NEXT
|
||||
|
||||
1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감
|
||||
2. Notes(쓰기) / Inbox(읽기·정리) 복귀 흐름과 30초 숨고르기 톤 정리
|
||||
3. Stage 가독성/모션/레이어 폴리시 최종 통일
|
||||
1. `/app` atmosphere entry shell 구현
|
||||
2. custom duration contract 정리
|
||||
3. weekly review dock 위치 재설계
|
||||
|
||||
## RISKS
|
||||
|
||||
- Free/Pro 제한은 클라이언트 local tier 기준이므로 서버에서 직접 막지 않는다
|
||||
- Free/Pro gating은 localStorage mock tier 기반이라 실제 구독 상태와 연결되지 않았다
|
||||
- `advance-goal`은 atomic endpoint 기준으로 동작하지만, 네트워크 실패 시 사용자는 현재 시트에서 재시도해야 한다
|
||||
- Session OS 도메인은 mock 기반이므로 실제 저장/복구 API 없이도 화면만 먼저 완성된 상태다
|
||||
- empty state에서 CTA는 살아 있지만 실제 시작 전에 입력 포커스가 먼저 필요하므로, 첫 진입 사용성은 브라우저 확인이 필요하다
|
||||
- `/space` paywall 전환 진입점은 `/app` / `/stats` 중심이라 execution 화면만 본 사용자에게는 업그레이드 맥락이 약할 수 있다
|
||||
- `/admin` 업로드 콘솔은 구조 복구가 끝났지만, 실제 파일 업로드 경로는 브라우저 수동 검증 전까지 확정할 수 없다
|
||||
- stage background overscan으로 좁은 화면에서 배경 crop이 조금 더 강하게 느껴질 수 있어 실기기 확인이 필요하다
|
||||
- remote manifest 실패 시 원인 진단은 가능해졌지만, 사용자용 복구 액션 UI는 아직 없다
|
||||
- alias 목록에 없는 legacy scene id가 추가되면 같은 fallback 문제가 재발할 수 있다
|
||||
- `npm run build`는 네트워크 제한 시 Google Font fetch 실패 가능
|
||||
- localStorage 포맷 변경 시 이전 세션 저장값과의 호환성 이슈 가능
|
||||
- Scene 추천값과 실제 사용자 선호가 어긋나면 자동 적용 체감 품질이 낮아질 수 있음
|
||||
@@ -182,6 +372,41 @@ Last Updated: 2026-03-05
|
||||
|
||||
## CHANGED FILES
|
||||
|
||||
- (이번 구조 재점검)
|
||||
- `src/widgets/admin-console/ui/AdminConsoleWidget.tsx`
|
||||
- `src/widgets/admin-console/ui/AdminDashboardView.tsx`
|
||||
- `src/features/admin/api/adminApi.ts`
|
||||
- `src/features/auth/types/index.ts`
|
||||
- `src/entities/auth/index.ts`
|
||||
- `src/entities/auth/model/useAuthStore.ts`
|
||||
- `src/features/auth/hooks/useSocialLogin.ts`
|
||||
- `src/features/auth/components/AuthRedirectButton.tsx`
|
||||
- `docs/foundation/02_arch_fsd_rules.md`
|
||||
- `docs/work.md`
|
||||
- `docs/90_current_state.md`
|
||||
- `docs/session_brief.md`
|
||||
- (이번 세션)
|
||||
- `src/app/(app)/app/page.tsx`
|
||||
- `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`
|
||||
- `src/widgets/focus-dashboard/ui/FocusPlanManageSheet.tsx`
|
||||
- `src/widgets/focus-dashboard/ui/FocusPlanListRow.tsx`
|
||||
- `src/entities/focus-plan/model/useFocusPlan.ts`
|
||||
- `src/widgets/focus-dashboard/index.ts`
|
||||
- `src/entities/plan/model/usePlanTier.ts`
|
||||
- `src/entities/session/model/focusSystem.ts`
|
||||
- `src/widgets/space-setup-drawer/ui/SpaceSetupDrawerWidget.tsx`
|
||||
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
|
||||
- `src/widgets/stats-overview/ui/StatsOverviewWidget.tsx`
|
||||
- `src/shared/i18n/messages/core.ts`
|
||||
- `src/shared/i18n/messages/app.ts`
|
||||
- `src/shared/i18n/messages/product.ts`
|
||||
- `src/shared/i18n/messages/space.ts`
|
||||
- `src/entities/media/model/types.ts`
|
||||
- `src/entities/media/model/resolveMediaAsset.ts`
|
||||
- `src/entities/media/model/useMediaCatalog.ts`
|
||||
- `docs/work.md`
|
||||
- `docs/90_current_state.md`
|
||||
- `docs/session_brief.md`
|
||||
- (최근 workflow 반영)
|
||||
- `src/widgets/space-workspace/ui/FocusTopToast.tsx`
|
||||
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
|
||||
@@ -199,11 +424,11 @@ Last Updated: 2026-03-05
|
||||
- `src/entities/room/model/types.ts`
|
||||
- `src/entities/room/model/rooms.ts`
|
||||
- `src/widgets/space-tools-dock/model/applyQuickPack.ts` (삭제)
|
||||
- `docs/06_commit_convention.md`
|
||||
- `docs/07_session_recovery.md`
|
||||
- `docs/foundation/06_commit_convention.md`
|
||||
- `docs/ops/07_session_recovery.md`
|
||||
- `docs/context_core.md`
|
||||
- `docs/session_brief.md`
|
||||
- `docs/workFlow.md`
|
||||
- `docs/ops/workFlow.md`
|
||||
- `docs/README.md`
|
||||
- `.gitmessage-session.txt`
|
||||
- `scripts/session/recover-context.sh`
|
||||
|
||||
145
docs/README.md
145
docs/README.md
@@ -1,28 +1,133 @@
|
||||
# Docs Index
|
||||
|
||||
Codex CLI가 중간에 끊겨도 같은 품질로 작업을 이어가기 위한 운영 문서 모음입니다.
|
||||
이 문서는 `web/docs`의 **메인 인덱스**다.
|
||||
문서를 찾을 때는 항상 여기서 시작한다.
|
||||
|
||||
## 우선 읽기 순서
|
||||
문서 상태는 3가지로 본다.
|
||||
|
||||
1. [work.md](./work.md)
|
||||
2. [session_brief.md](./session_brief.md)
|
||||
3. [90_current_state.md](./90_current_state.md)
|
||||
4. [context_core.md](./context_core.md)
|
||||
- `source-of-truth`: 현재 기획/구현의 기준 문서
|
||||
- `ops`: 작업 운영, 핸드오프, 세션 복구용 문서
|
||||
- `legacy`: 과거 맥락을 이해할 때만 보는 참고 문서
|
||||
|
||||
## 추가 실무 가이드
|
||||
## 가장 먼저 읽기
|
||||
|
||||
- [04_coding_rules.md](./04_coding_rules.md)
|
||||
- [05_handoff_checklist.md](./05_handoff_checklist.md)
|
||||
- [06_commit_convention.md](./06_commit_convention.md)
|
||||
- [07_session_recovery.md](./07_session_recovery.md)
|
||||
새 세션이나 새 에이전트가 들어오면 이 순서로 읽는다.
|
||||
|
||||
1. [work.md](./work.md) `source-of-truth`
|
||||
2. [session_brief.md](./session_brief.md) `source-of-truth`
|
||||
3. [90_current_state.md](./90_current_state.md) `source-of-truth`
|
||||
4. [context_core.md](./context_core.md) `ops`
|
||||
5. 이 문서에서 지금 다루는 화면 섹션 확인
|
||||
|
||||
## 제품 전체 기획
|
||||
|
||||
화면 하나가 아니라 제품 전체 방향을 볼 때 읽는다.
|
||||
|
||||
- [00_project_brief.md](./foundation/00_project_brief.md) `source-of-truth`
|
||||
- 프로젝트 목적, 범위, 현재 제품 성격
|
||||
- [12_core_loop_execution_roadmap.md](./product/12_core_loop_execution_roadmap.md) `source-of-truth`
|
||||
- 어떤 순서로 구현할지 정리한 로드맵
|
||||
- [16_product_alignment_audit_plan.md](./product/16_product_alignment_audit_plan.md) `source-of-truth`
|
||||
- 기획-구현 불일치 점검 계획
|
||||
- [17_product_alignment_findings.md](./product/17_product_alignment_findings.md) `source-of-truth`
|
||||
- 실제 불일치 ledger
|
||||
- [08_app_reframe_strategy.md](./screens/app/archive/08_app_reframe_strategy.md) `legacy`
|
||||
- 초반 `/app` 방향 논의용 아이디어 문서
|
||||
|
||||
## 화면별 기획 문서
|
||||
|
||||
### `/app`
|
||||
|
||||
`/app` 진입 경험, paused gate, review entry를 볼 때 읽는다.
|
||||
|
||||
- [screens/app/README.md](./screens/app/README.md) `source-of-truth`
|
||||
- `/app` current/archive 인덱스
|
||||
- [19_app_atmosphere_entry_spec.md](./screens/app/current/19_app_atmosphere_entry_spec.md) `source-of-truth`
|
||||
- `goal + duration + atmosphere` 중심의 새 `/app` 설계
|
||||
|
||||
### `/space`
|
||||
|
||||
`/space` HUD, refocus, break/return, goal card를 볼 때 읽는다.
|
||||
|
||||
- [screens/space/README.md](./screens/space/README.md) `source-of-truth`
|
||||
- `/space` current/archive 인덱스
|
||||
- [13_space_intent_card_collapsed_expanded_spec.md](./screens/space/current/13_space_intent_card_collapsed_expanded_spec.md) `source-of-truth`
|
||||
- 좌상단 목표 카드 구조
|
||||
|
||||
### `/stats`
|
||||
|
||||
review 구조와 BM 연결을 볼 때 읽는다.
|
||||
|
||||
- [screens/stats/README.md](./screens/stats/README.md) `source-of-truth`
|
||||
- `/stats` current/archive 인덱스
|
||||
- [14_weekly_review_reframe_spec.md](./screens/stats/current/14_weekly_review_reframe_spec.md) `source-of-truth`
|
||||
- weekly review를 행동 시스템으로 재정의한 문서
|
||||
|
||||
## 플로우 문서
|
||||
|
||||
두 화면 이상을 가로지르는 문서는 별도로 관리한다.
|
||||
|
||||
- [flows/README.md](./flows/README.md) `source-of-truth`
|
||||
- [10_refocus_system_spec.md](./flows/current/10_refocus_system_spec.md) `source-of-truth`
|
||||
- [11_away_return_recovery_spec.md](./flows/current/11_away_return_recovery_spec.md) `source-of-truth`
|
||||
- [15_app_stats_entry_flow_spec.md](./flows/current/15_app_stats_entry_flow_spec.md) `source-of-truth`
|
||||
- [18_paused_session_reentry_spec.md](./flows/current/18_paused_session_reentry_spec.md) `source-of-truth`
|
||||
|
||||
## 개발 시 참고 문서
|
||||
|
||||
구현 규칙이나 구조를 볼 때 읽는다.
|
||||
|
||||
- [08_premium_uiux_guideline.md](./foundation/08_premium_uiux_guideline.md) `source-of-truth`
|
||||
- 세계 최고급(LifeAt, Portal 수준) UI/UX 톤앤매너, 글래스모피즘, 모션 등 프리미엄 디자인 절대 원칙
|
||||
- 중요: UI 작업 시 AI가 새 view를 임의로 그리지 않고, 사용자가 만든 현재 view를 source of truth로 재사용/확장해야 한다
|
||||
- [01_ui_guidelines.md](./foundation/01_ui_guidelines.md) `source-of-truth`
|
||||
- UI 톤, CTA 위계, premium 품질 기준 (일반 가이드)
|
||||
- [02_arch_fsd_rules.md](./foundation/02_arch_fsd_rules.md) `source-of-truth`
|
||||
- FSD/레이어 구조 규칙
|
||||
- [03_routes_map.md](./foundation/03_routes_map.md) `source-of-truth`
|
||||
- 라우트와 주요 화면 진입점
|
||||
- [04_coding_rules.md](./foundation/04_coding_rules.md) `source-of-truth`
|
||||
- 코드 작성 규칙
|
||||
- [06_commit_convention.md](./foundation/06_commit_convention.md) `source-of-truth`
|
||||
- 커밋 규칙
|
||||
|
||||
## 운영 / 핸드오프 문서
|
||||
|
||||
작업 재개, 세션 복구, handoff용 문서다.
|
||||
|
||||
- [05_handoff_checklist.md](./ops/05_handoff_checklist.md) `ops`
|
||||
- [07_session_recovery.md](./ops/07_session_recovery.md) `ops`
|
||||
- [context_core.md](./context_core.md) `ops`
|
||||
- [workFlow.md](./ops/workFlow.md) `ops`
|
||||
- [work.template.md](./work.template.md) `ops`
|
||||
|
||||
## 실시간 상태 문서
|
||||
|
||||
지금 무엇을 하고 있고, 다음 작업이 무엇인지 볼 때 읽는다.
|
||||
|
||||
- [work.md](./work.md) `source-of-truth`
|
||||
- [session_brief.md](./session_brief.md) `source-of-truth`
|
||||
- [90_current_state.md](./90_current_state.md) `source-of-truth`
|
||||
|
||||
## 빠른 선택 가이드
|
||||
|
||||
- `/app`을 수정한다:
|
||||
- [screens/app/README.md](./screens/app/README.md)
|
||||
- [19_app_atmosphere_entry_spec.md](./screens/app/current/19_app_atmosphere_entry_spec.md)
|
||||
- [18_paused_session_reentry_spec.md](./flows/current/18_paused_session_reentry_spec.md)
|
||||
- [15_app_stats_entry_flow_spec.md](./flows/current/15_app_stats_entry_flow_spec.md)
|
||||
- `/space` recovery를 수정한다:
|
||||
- [screens/space/README.md](./screens/space/README.md)
|
||||
- [10_refocus_system_spec.md](./flows/current/10_refocus_system_spec.md)
|
||||
- [11_away_return_recovery_spec.md](./flows/current/11_away_return_recovery_spec.md)
|
||||
- [13_space_intent_card_collapsed_expanded_spec.md](./screens/space/current/13_space_intent_card_collapsed_expanded_spec.md)
|
||||
- [08_premium_uiux_guideline.md](./foundation/08_premium_uiux_guideline.md)
|
||||
- 현재 사용자가 만든 `/space` view를 그대로 재사용/확장하고, 새 shell을 임의로 만들지 않는다
|
||||
- `/stats`를 수정한다:
|
||||
- [screens/stats/README.md](./screens/stats/README.md)
|
||||
- [14_weekly_review_reframe_spec.md](./screens/stats/current/14_weekly_review_reframe_spec.md)
|
||||
- [15_app_stats_entry_flow_spec.md](./flows/current/15_app_stats_entry_flow_spec.md)
|
||||
- 지금 다음 작업이 뭔지 본다:
|
||||
- [work.md](./work.md)
|
||||
- [workFlow.md](./workFlow.md)
|
||||
- [session_brief.md](./session_brief.md)
|
||||
|
||||
## 운영 원칙
|
||||
|
||||
- 구현 범위는 항상 UI 목업 + 더미 데이터 + 토스트 수준으로 유지한다.
|
||||
- `page.tsx`는 조합만 담당하고 비즈니스 로직은 `features/widgets/entities`로 이동한다.
|
||||
- 작업 종료 시 `90_current_state.md`를 반드시 업데이트한다.
|
||||
- 세션 복구는 `npm run session:recover`로 시작한다.
|
||||
- `workFlow.md` 실행 시 기본은 `work + session_brief` 2파일만 로드한다.
|
||||
- [90_current_state.md](./90_current_state.md)
|
||||
|
||||
@@ -1,43 +1,56 @@
|
||||
# Context Core (Token-Saving)
|
||||
# Context Core
|
||||
|
||||
세션 시작 시 가장 먼저 읽는 핵심 요약본이다.
|
||||
세션 시작 시 빠르게 읽는 **초단기 핵심 요약본**이다.
|
||||
|
||||
## 제품/범위
|
||||
전체 문서 구조는 [README.md](./README.md)에서 본다.
|
||||
|
||||
- VibeRoom Web은 UI 목업 중심 프로젝트다.
|
||||
- 실제 기능(타이머 카운트다운, 오디오 엔진, 서버/DB)은 구현하지 않는다.
|
||||
- 더미 데이터 + 토스트 + 화면 상호작용 중심으로 구현한다.
|
||||
## 제품 핵심
|
||||
|
||||
## FSD 핵심 규칙
|
||||
- VibeRoom Web은 ADHD와 프리랜서를 위한 premium focus service다.
|
||||
- 핵심 루프는 `commit -> enter -> immerse -> pause/return -> complete -> review`다.
|
||||
- `/app`은 planning dashboard가 아니라 **entry stage**다.
|
||||
- `/space`는 dashboard가 아니라 **execution sanctuary**다.
|
||||
- `/stats`는 summary page가 아니라 **다음 세션 성공률을 높이는 review**다.
|
||||
|
||||
- `app/page.tsx`는 조합만 담당한다.
|
||||
- 비즈니스 로직은 `features` 또는 `entities`로 이동한다.
|
||||
- UI 상태(토글/선택)만 최소 허용한다.
|
||||
- 파일이 500줄 이상이면 분리한다.
|
||||
- import 방향:
|
||||
## 현재 화면 기준
|
||||
|
||||
### `/app`
|
||||
|
||||
- current session이 `running`이면 `/space`가 우선이다.
|
||||
- current session이 `paused`이면 `/app` resume gate가 우선이다.
|
||||
- no-session 상태의 source-of-truth는 `goal + duration + atmosphere` entry shell이다.
|
||||
- `microStep`은 `/app`에서 입력하지 않는다.
|
||||
|
||||
### `/space`
|
||||
|
||||
- goal card는 collapsed / expanded rail 구조다.
|
||||
- `pause`, `return`, `next beat`, `goal complete`는 한 번에 하나만 열린다.
|
||||
- active session에서는 closure 경로가 항상 남아 있어야 한다.
|
||||
|
||||
### `/stats`
|
||||
|
||||
- review는 `snapshot / start quality / recovery quality / completion quality / carry forward` 구조다.
|
||||
- primary entry는 `/app`, secondary entry는 `/space` complete 이후 setup이다.
|
||||
|
||||
## 구현 규칙 요약
|
||||
|
||||
- `page.tsx`는 조합만 담당한다.
|
||||
- 레이어 규칙:
|
||||
- `app -> widgets/features/entities/shared`
|
||||
- `widgets -> features/entities/shared`
|
||||
- `features -> entities/shared`
|
||||
- `entities -> shared`
|
||||
- `shared -> shared/external`
|
||||
- UI는 premium ambient focus 톤을 유지한다.
|
||||
- 한 화면에는 primary CTA를 1개만 둔다.
|
||||
|
||||
## UI 핵심 규칙
|
||||
## 작업 시작 순서
|
||||
|
||||
- 톤: 감성/저자극, 과한 대비/강조 금지
|
||||
- CTA 위계: Primary 1개 중심, Secondary/Tertiary는 무게 낮게
|
||||
- 모바일은 접근성 우선, 데스크톱은 과한 풀폭 버튼 지양
|
||||
1. [work.md](./work.md)
|
||||
2. [session_brief.md](./session_brief.md)
|
||||
3. [90_current_state.md](./90_current_state.md)
|
||||
4. 지금 건드릴 화면의 source-of-truth spec
|
||||
|
||||
## 커밋 규칙 (요약)
|
||||
## 주의
|
||||
|
||||
- 주제별 1커밋
|
||||
- 한국어 Conventional Commit
|
||||
- 본문 형식:
|
||||
- `맥락`
|
||||
- `변경사항`
|
||||
- `검증`
|
||||
- `세션-상태 / 세션-다음 / 세션-리스크`
|
||||
|
||||
## 세션 복구 규칙
|
||||
|
||||
- 시작: `npm run session:recover`
|
||||
- 상태판: `docs/90_current_state.md` 기준으로 다음 작업 결정
|
||||
- 오래된 `/app` single-goal gate 문서는 legacy다.
|
||||
- 문서가 충돌하면 [README.md](./README.md)의 `source-of-truth` 분류가 우선이다.
|
||||
|
||||
20
docs/flows/README.md
Normal file
20
docs/flows/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Flow Docs
|
||||
|
||||
여기는 특정 화면 하나에 속하지 않고, 두 화면 이상을 가로지르는 플로우 문서를 둔다.
|
||||
|
||||
예:
|
||||
|
||||
- `/app -> /stats -> /app`
|
||||
- paused session re-entry
|
||||
- refocus / away-return recovery
|
||||
|
||||
## current
|
||||
|
||||
- [10_refocus_system_spec.md](./current/10_refocus_system_spec.md)
|
||||
- [11_away_return_recovery_spec.md](./current/11_away_return_recovery_spec.md)
|
||||
- [15_app_stats_entry_flow_spec.md](./current/15_app_stats_entry_flow_spec.md)
|
||||
- [18_paused_session_reentry_spec.md](./current/18_paused_session_reentry_spec.md)
|
||||
|
||||
## archive
|
||||
|
||||
- 현재 없음
|
||||
487
docs/flows/current/10_refocus_system_spec.md
Normal file
487
docs/flows/current/10_refocus_system_spec.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# 10. Refocus System Spec
|
||||
|
||||
Last Updated: 2026-03-15
|
||||
|
||||
이 문서는 VibeRoom의 `Refocus System`을 제품 대표 경험으로 설계하기 위한 상세 기준 문서다.
|
||||
|
||||
관련 상위 문서:
|
||||
|
||||
- `../../product_principles.md`
|
||||
- `../../current_context.md`
|
||||
- `../app/19_app_atmosphere_entry_spec.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 왜 Refocus가 핵심인가
|
||||
|
||||
VibeRoom의 차별점은 `더 오래 계획하게 만드는 것`이 아니라,
|
||||
`흔들린 뒤에도 다시 집중 위에 올라타게 만드는 것`이어야 한다.
|
||||
|
||||
ADHD 성향 사용자와 프리랜서에게 진짜 어려운 순간은 대개 아래 셋 중 하나다.
|
||||
|
||||
- 시작 직전
|
||||
- 잠깐 멈췄다가 다시 붙잡아야 할 때
|
||||
- 한 조각을 끝냈는데 다음 동작이 흐려졌을 때
|
||||
|
||||
`/app`이 시작 마찰을 줄이는 화면이라면,
|
||||
`Refocus System`은 **세션 도중 무너진 흐름을 다시 복구하는 핵심 시스템**이다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 레퍼런스에서 가져올 것 / 버릴 것
|
||||
|
||||
이 문서는 2026-03-14 기준 공식 사이트를 기준으로 아래 레퍼런스를 참고했다.
|
||||
|
||||
- [Portal](https://portal.app/)
|
||||
- [LifeAt Pricing](https://lifeat.io/pricing)
|
||||
- [Focusmate Pricing](https://www.focusmate.com/pricing/)
|
||||
- [Focusmate Getting Started](https://support.focusmate.com/en/articles/9110188-getting-started)
|
||||
- [Focusmate Focus Now](https://support.focusmate.com/en/articles/9994509-focus-now)
|
||||
|
||||
### Portal에서 가져올 것
|
||||
|
||||
- 배경이 주인공이고 인터페이스는 조용히 뒤로 물러나야 한다
|
||||
- 감각 품질 자체가 제품 가치가 된다
|
||||
- “아름답다”가 장식이 아니라 사용 지속 이유가 된다
|
||||
|
||||
### Focusmate에서 가져올 것
|
||||
|
||||
- 행동을 시작시키는 명확한 구조
|
||||
- 사용자가 망설이지 않게 하는 단일 흐름
|
||||
- `지금 바로 들어간다`는 즉시성
|
||||
|
||||
### LifeAt에서 가져올 것
|
||||
|
||||
- 가볍게 시작할 수 있는 친화성
|
||||
- 공간과 집중을 연결하는 감성
|
||||
|
||||
### LifeAt에서 가져오면 안 되는 것
|
||||
|
||||
- planner / todo / calendar 중심 구조
|
||||
- 목표를 많이 다루게 하는 흐름
|
||||
- “집중 앱”보다 “정리 앱”처럼 읽히는 경험
|
||||
|
||||
---
|
||||
|
||||
## 3. 한 줄 정의
|
||||
|
||||
> Refocus는 사용자가 흔들렸을 때 죄책감 없이 다시 한 조각 위에 올라타게 만드는 조용한 복귀 의식이다.
|
||||
|
||||
중요한 점:
|
||||
|
||||
- `수정 기능`이 아니다
|
||||
- `체크리스트`가 아니다
|
||||
- `왜 못 했는지 묻는 평가 시스템`이 아니다
|
||||
|
||||
---
|
||||
|
||||
## 4. 제품 목표
|
||||
|
||||
Refocus System은 아래 4가지를 만족해야 한다.
|
||||
|
||||
1. 사용자가 세션을 포기하지 않게 한다
|
||||
2. pause 이후 복귀 마찰을 줄인다
|
||||
3. microStep을 다시 잡아 실행을 재개하게 한다
|
||||
4. UI가 배경과 몰입을 방해하지 않는다
|
||||
|
||||
---
|
||||
|
||||
## 5. 시스템 원칙
|
||||
|
||||
### 1. Refocus는 한 번에 한 질문만 한다
|
||||
|
||||
질문을 여러 개 던지면 planner처럼 느껴진다.
|
||||
항상 다음 하나만 물어야 한다.
|
||||
|
||||
- 계속할까?
|
||||
- 지금 할 한 조각은 무엇일까?
|
||||
- 이 목표를 끝낼까?
|
||||
|
||||
### 2. 사용자를 평가하지 않는다
|
||||
|
||||
금지:
|
||||
|
||||
- 왜 못 했어요?
|
||||
- 얼마나 산만했나요?
|
||||
- 다시 집중하세요
|
||||
|
||||
허용:
|
||||
|
||||
- 다음 한 조각이 있나요?
|
||||
- 지금 다시 시작할 수 있게 한 줄만 남겨볼까요?
|
||||
- 여기까지로 충분한가요?
|
||||
|
||||
### 3. goal은 1개, microStep도 1개
|
||||
|
||||
Refocus는 절대 리스트 관리 UI가 되면 안 된다.
|
||||
|
||||
- multi-step 금지
|
||||
- queue 금지
|
||||
- subtask list 금지
|
||||
|
||||
### 4. 배경은 항상 주인공이다
|
||||
|
||||
Refocus UI는 강한 모달이 아니라 **배경 위에 잠깐 생겼다가 사라지는 얇은 recovery layer**여야 한다.
|
||||
|
||||
### 5. Premium은 절제에서 온다
|
||||
|
||||
좋은 Refocus는 화려한 카드나 모션이 아니라 아래에서 온다.
|
||||
|
||||
- 올바른 정보 밀도
|
||||
- 1개의 명확한 행동
|
||||
- 자연스러운 등장과 퇴장
|
||||
- 배경과 어우러지는 재질감
|
||||
|
||||
---
|
||||
|
||||
## 6. Refocus가 필요한 순간들
|
||||
|
||||
Refocus는 아래 4개 진입점으로 고정한다.
|
||||
|
||||
### A. Pause 직후
|
||||
|
||||
사용자가 세션을 멈춘 뒤 다시 돌아와야 하는 순간.
|
||||
|
||||
### B. MicroStep 완료 직후
|
||||
|
||||
현재 한 조각은 끝났지만 다음 행동이 아직 흐린 순간.
|
||||
|
||||
### C. 사용자가 의도를 직접 수정하고 싶을 때
|
||||
|
||||
goal이나 microStep을 다시 정리하고 싶은 수동 진입.
|
||||
|
||||
### D. 세션에서 멀어졌다가 복귀했을 때
|
||||
|
||||
탭을 바꾸거나 잠깐 이탈한 후 다시 `/space`로 돌아온 상황.
|
||||
|
||||
---
|
||||
|
||||
## 7. 화면 상태 모델
|
||||
|
||||
Refocus는 아래 5개 상태만 가진다.
|
||||
|
||||
1. `Focused`
|
||||
2. `Paused`
|
||||
3. `Refocus`
|
||||
4. `Next Beat`
|
||||
5. `Complete`
|
||||
|
||||
한 번에 활성화되는 확장 상태는 하나만 허용한다.
|
||||
|
||||
- `Refocus`
|
||||
- `Next Beat`
|
||||
- `Complete`
|
||||
|
||||
이 셋은 동시에 보이면 안 된다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 상태별 상세 UX
|
||||
|
||||
### CTA Matrix
|
||||
|
||||
Refocus family에서 중요한 원칙은 하나다.
|
||||
|
||||
> 세션이 아직 살아 있는 상태라면, 사용자는 언제든 `계속 / 다시 잡기 / 마무리` 중 하나로 갈 수 있어야 한다.
|
||||
|
||||
상태별 CTA는 아래처럼 고정한다.
|
||||
|
||||
| 상태 | 보여야 하는 주 행동 | goal complete 접근 |
|
||||
| --- | --- | --- |
|
||||
| `Focused` | `수정`, `microStep 완료`, `이번 목표 완료`, timer `pause/reset` | base intent card에서 직접 노출 |
|
||||
| `Paused` | `한 조각 다시 잡기`, `바로 이어가기` | pause tray의 low-emphasis `여기서 마무리하기` |
|
||||
| `Return (focus)` | `멈춘 자리에서 이어가기`, `한 조각 다시 잡기` | return tray의 low-emphasis `여기서 마무리하기` |
|
||||
| `Break` | break 유지, 다음 블록으로 이어가기, goal closure | base intent card에서 직접 노출 |
|
||||
| `Return (break)` | `쉬기 이어가기`, `다음 블록 이어가기`, `한 조각 다시 잡기` | return tray의 low-emphasis `여기서 마무리하기` |
|
||||
| `Next Beat` | `목표만 두고 계속하기`, `다음 단계 적기` | next-beat tray의 low-emphasis `이 목표는 여기서 마무리하기` |
|
||||
|
||||
`Refocus` 편집 시트 자체는 편집에만 집중하고, complete 액션은 별도 decision tray나 base intent card에서 수행한다.
|
||||
|
||||
### 8.1 Focused
|
||||
|
||||
목표:
|
||||
|
||||
- 사용자가 UI를 거의 의식하지 않고 일하는 상태
|
||||
|
||||
보여야 하는 것:
|
||||
|
||||
- goal
|
||||
- optional microStep
|
||||
- timer HUD
|
||||
- minimal controls
|
||||
|
||||
보이면 안 되는 것:
|
||||
|
||||
- 질문
|
||||
- 복구 유도 문구
|
||||
- task-like affordance
|
||||
|
||||
### 8.2 Paused
|
||||
|
||||
목표:
|
||||
|
||||
- 세션이 끊긴 느낌이 아니라 잠시 호흡을 고르는 느낌
|
||||
|
||||
행동:
|
||||
|
||||
- 바로 큰 sheet를 띄우지 않는다
|
||||
- HUD 상태가 paused로 바뀌고,
|
||||
- `다시 시작할 준비가 되면 한 조각만 다시 잡는다`는 감각을 준다
|
||||
|
||||
### 8.3 Refocus
|
||||
|
||||
목표:
|
||||
|
||||
- 목표를 버리지 않고 다시 시작점을 만든다
|
||||
|
||||
UI:
|
||||
|
||||
- anchored tray 또는 compact sheet
|
||||
- 필드 2개
|
||||
- 이번 세션 목표
|
||||
- 지금 할 한 조각
|
||||
- CTA 2개
|
||||
- 적용
|
||||
- 취소
|
||||
|
||||
중요:
|
||||
|
||||
- `다시 방향 잡기`는 메인 액션이 아니라 recovery action이어야 한다
|
||||
- button cluster처럼 보이면 안 된다
|
||||
|
||||
### 8.4 Next Beat
|
||||
|
||||
목표:
|
||||
|
||||
- microStep 완료 후 checklist가 아니라 “다음 한 조각”으로 연결
|
||||
|
||||
질문:
|
||||
|
||||
> 다음 한 조각이 있나요?
|
||||
|
||||
행동:
|
||||
|
||||
- `한 조각 정하기`
|
||||
- `없이 계속`
|
||||
|
||||
금지:
|
||||
|
||||
- 다음 step list 펼치기
|
||||
- 여러 개 제안
|
||||
- 정리형 UI
|
||||
|
||||
### 8.5 Complete
|
||||
|
||||
목표:
|
||||
|
||||
- 목표가 끝났을 때 닫음과 다음 시작을 모두 부드럽게 연결
|
||||
|
||||
질문:
|
||||
|
||||
- 여기까지 끝났나요?
|
||||
|
||||
행동:
|
||||
|
||||
- 여기서 마무리하기
|
||||
- 다음 블록 이어가기
|
||||
- 잠시 비우기
|
||||
|
||||
완료는 celebration보다 **closure quality**가 중요하다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 상세 플로우
|
||||
|
||||
### Flow A. Pause -> Refocus -> Resume
|
||||
|
||||
1. 사용자가 pause 한다
|
||||
2. UI는 즉시 평가하지 않는다
|
||||
3. 사용자가 resume을 누르거나 intent를 누르면 Refocus로 들어갈 수 있다
|
||||
4. 사용자는 goal / microStep 중 필요한 것만 조정한다
|
||||
5. `적용 후 이어가기` 또는 `적용` 뒤 resume
|
||||
|
||||
목표:
|
||||
|
||||
- pause 이후 바로 복귀하는 것이 아니라,
|
||||
- 한 번 숨을 고르고 다시 붙잡게 만든다
|
||||
|
||||
### Flow B. MicroStep Complete -> Next Beat
|
||||
|
||||
1. 사용자가 microStep completion mark를 누른다
|
||||
2. 기존 microStep은 조용히 처리된다
|
||||
3. `다음 한 조각이 있나요?`가 나타난다
|
||||
4. 사용자는
|
||||
- 새 microStep을 적거나
|
||||
- 없이 계속 간다
|
||||
|
||||
목표:
|
||||
|
||||
- planner처럼 다음 목록을 만드는 것이 아니라
|
||||
- 지금 하나만 다시 붙잡게 한다
|
||||
|
||||
### Flow C. Manual Refocus
|
||||
|
||||
1. 사용자가 goal 영역을 눌러 의도 수정 진입
|
||||
2. goal / microStep 수정
|
||||
3. 적용
|
||||
4. 조용히 복귀
|
||||
|
||||
목표:
|
||||
|
||||
- 세션을 깨지 않는 범위에서 intent만 조정
|
||||
|
||||
### Flow D. Goal Complete -> Next Goal or Close
|
||||
|
||||
1. 사용자가 goal complete 진입
|
||||
2. 현재 목표를 닫을지, 다음 목표로 이어갈지 선택
|
||||
3. 다음 목표는 여전히 1개만 다룬다
|
||||
|
||||
목표:
|
||||
|
||||
- checklist가 아니라 clean transition
|
||||
|
||||
---
|
||||
|
||||
## 10. UI 구조
|
||||
|
||||
### 레이어 구조
|
||||
|
||||
#### Layer 1. Background
|
||||
|
||||
- 배경 이미지/영상
|
||||
- 주인공
|
||||
|
||||
#### Layer 2. Core HUD
|
||||
|
||||
- timer
|
||||
- sound / scene controls
|
||||
- intent card
|
||||
|
||||
#### Layer 3. Recovery Layer
|
||||
|
||||
- refocus tray
|
||||
- next beat prompt
|
||||
- complete tray
|
||||
|
||||
Recovery Layer는 Core HUD와 같은 material family를 가져야 한다.
|
||||
다만 더 조용하고 얇아야 한다.
|
||||
|
||||
### Material 방향
|
||||
|
||||
- iOS 계열의 refined glass 참고
|
||||
- 강한 탁도보다 `투명 + blur + 얕은 경계`를 우선
|
||||
- glow, heavy shadow, thick border 금지
|
||||
- chip 남발 금지
|
||||
|
||||
### 모션 방향
|
||||
|
||||
- 빠르게 튀어나오지 않는다
|
||||
- appear 220~280ms
|
||||
- disappear 180~220ms
|
||||
- scale보다는 opacity + position shift 위주
|
||||
|
||||
---
|
||||
|
||||
## 11. 카피라이팅 방향
|
||||
|
||||
### tone
|
||||
|
||||
- 짧다
|
||||
- 저압력이다
|
||||
- 평가하지 않는다
|
||||
- 다시 시작할 수 있게 한다
|
||||
|
||||
### 좋은 예
|
||||
|
||||
- `지금 할 한 조각`
|
||||
- `다음 한 조각이 있나요?`
|
||||
- `한 줄만 다듬고 다시 시작해요.`
|
||||
- `여기까지로 충분한가요?`
|
||||
|
||||
### 피해야 할 예
|
||||
|
||||
- `할 일`
|
||||
- `리스트`
|
||||
- `관리`
|
||||
- `다음 단계들`
|
||||
- `왜 못 했나요?`
|
||||
|
||||
---
|
||||
|
||||
## 12. Free / Pro에서의 역할
|
||||
|
||||
### Free
|
||||
|
||||
- 기본 refocus 진입
|
||||
- goal / microStep 재설정
|
||||
- next beat prompt
|
||||
- goal complete 기본 흐름
|
||||
|
||||
### Pro
|
||||
|
||||
- 더 정교한 refocus guidance
|
||||
- 세션 패턴 기반 microStep 제안
|
||||
- 어떤 ritual에서 복귀율이 높은지 review에 반영
|
||||
|
||||
중요:
|
||||
|
||||
Pro는 `더 많은 목표`가 아니라 `더 높은 복귀 성공률`을 파는 쪽으로 가야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 13. 성공 지표
|
||||
|
||||
Refocus System은 아래 지표로 판단한다.
|
||||
|
||||
- Pause 후 Resume 비율
|
||||
- Pause 후 Refocus 진입 비율
|
||||
- Refocus 후 2분 이상 유지 비율
|
||||
- MicroStep 완료 후 Next Beat 입력 비율
|
||||
- Goal complete 후 다음 세션 전환 비율
|
||||
- 세션 중 abandon 비율 감소
|
||||
|
||||
---
|
||||
|
||||
## 14. 구현 우선순위
|
||||
|
||||
### Slice 1
|
||||
|
||||
- pause 이후 Refocus 진입 구조 정리
|
||||
- goal / microStep 수정 tray 정교화
|
||||
- 한 번에 하나의 overlay만 뜨도록 상태 정리
|
||||
|
||||
### Slice 2
|
||||
|
||||
- microStep 완료 -> Next Beat 흐름 정리
|
||||
- checklist 느낌 제거
|
||||
|
||||
### Slice 3
|
||||
|
||||
- goal complete tray의 완성도 향상
|
||||
- closure / next goal / break 분기 정리
|
||||
|
||||
### Slice 4
|
||||
|
||||
- Pro용 adaptive refocus 기획
|
||||
- review와 refocus 연결
|
||||
|
||||
---
|
||||
|
||||
## 15. 절대 피해야 할 방향
|
||||
|
||||
- pause를 실패처럼 느끼게 하는 UX
|
||||
- checklist UI
|
||||
- multi-step planner화
|
||||
- 과한 그래프 / 통계 immediate 노출
|
||||
- 배경보다 더 앞에 나오는 recovery UI
|
||||
- action chip 남발
|
||||
- 한 번에 여러 질문을 던지는 sheet
|
||||
|
||||
---
|
||||
|
||||
## 16. 이 문서를 기준으로 다음 구현에서 꼭 지켜야 할 것
|
||||
|
||||
- Refocus는 `편집 기능`이 아니라 `recovery ritual`처럼 느껴져야 한다
|
||||
- 사용자는 한 번에 하나의 행동만 선택해야 한다
|
||||
- 배경은 절대 희생시키지 않는다
|
||||
- premium quality는 더 많은 glass가 아니라 더 적은 friction에서 나온다
|
||||
441
docs/flows/current/11_away_return_recovery_spec.md
Normal file
441
docs/flows/current/11_away_return_recovery_spec.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# 11. Away / Return Recovery Spec
|
||||
|
||||
Last Updated: 2026-03-14
|
||||
|
||||
이 문서는 사용자가 `pause`를 누르지 않고 그냥 자리를 떠난 경우를 VibeRoom이 어떻게 감지하고, 어떻게 맞이하고, 어떻게 다시 복귀시킬지를 정의하는 상세 기준 문서다.
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `../../product_principles.md`
|
||||
- `../../current_context.md`
|
||||
- `./10_refocus_system_spec.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 왜 이 기획이 중요한가
|
||||
|
||||
ADHD 성향 사용자와 프리랜서는 자주 아래처럼 이탈한다.
|
||||
|
||||
- `pause`를 누를 생각도 못 한 채 자리에서 일어남
|
||||
- 잠깐 딴 탭을 보다가 세션 흐름을 잃음
|
||||
- focus가 끝났는지도 모른 채 돌아옴
|
||||
|
||||
이 상황을 제품이 다루지 않으면 아래 문제가 생긴다.
|
||||
|
||||
- Refocus가 “사용자가 pause를 눌렀을 때만 작동하는 기능”으로 축소된다
|
||||
- 세션이 실제 흐름보다 더 기계적으로 느껴진다
|
||||
- `pause`와 `break`가 같은 “멈춘 상태”처럼 읽힌다
|
||||
- 돌아온 사용자를 잘못된 상태로 맞이하게 된다
|
||||
|
||||
VibeRoom은 감시 앱이 되어서는 안 되지만,
|
||||
**돌아왔을 때의 복귀 품질**은 반드시 설계해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 한 줄 정의
|
||||
|
||||
> Away / Return은 사용자의 무의식적 이탈을 실패로 다루지 않고, 돌아왔을 때 가장 자연스럽게 다시 몰입 위에 올려놓는 복귀 레이어다.
|
||||
|
||||
중요한 점:
|
||||
|
||||
- 사용자를 통제하거나 막는 기능이 아니다
|
||||
- “왜 떠났는가”를 추궁하지 않는다
|
||||
- 핵심은 detection보다 **return UX**다
|
||||
|
||||
---
|
||||
|
||||
## 3. 이 시스템이 해결해야 하는 문제
|
||||
|
||||
### 문제 A. 사용자는 `pause`를 누르지 않는다
|
||||
|
||||
사용자는 실제로는 쉬고 싶어서 일어난 것이어도,
|
||||
제품 안에서는 아무 액션도 하지 않은 채 세션이 흘러간다.
|
||||
|
||||
### 문제 B. focus가 끝났는데 break처럼 보인다
|
||||
|
||||
사용자가 없던 사이 focus가 끝났다면, 돌아왔을 때 단순 `Break`로 맞이하면 안 된다.
|
||||
그건 제품이 사용자의 실제 맥락을 오해한 것이다.
|
||||
|
||||
### 문제 C. pause와 break가 겹쳐 보인다
|
||||
|
||||
현재 감정적으로는 이렇게 읽히기 쉽다.
|
||||
|
||||
- `Pause`: 내가 멈춤
|
||||
- `Break`: focus가 끝나서 쉬는 중
|
||||
|
||||
둘은 의미가 다른데, UI와 상태가 충분히 분리되지 않으면 같은 “멈춤”처럼 느껴진다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 원칙
|
||||
|
||||
### 1. 감시는 하지 않는다
|
||||
|
||||
아래는 금지한다.
|
||||
|
||||
- webcam / face tracking
|
||||
- 키보드/마우스 무입력만으로 강제 이탈 판정
|
||||
- 사용자를 혼내는 알림
|
||||
- “집중하지 않았네요” 류의 카피
|
||||
|
||||
### 2. 확실한 신호만 사용한다
|
||||
|
||||
웹에서 “자리 비움”은 정확히 알 수 없다.
|
||||
따라서 v1은 아래처럼 비교적 강한 신호만 쓴다.
|
||||
|
||||
- `visibilitychange`
|
||||
- `pagehide`
|
||||
- 브라우저/기기 sleep 이후 큰 시간 점프
|
||||
- 창 복귀 시점
|
||||
|
||||
### 3. detection보다 return이 중요하다
|
||||
|
||||
중요한 것은 “네가 떠났다는 걸 알아냈다”가 아니라,
|
||||
**“돌아온 지금 무엇을 제안할 것인가”**다.
|
||||
|
||||
### 4. Pause와 Break는 명확히 다르게 느껴져야 한다
|
||||
|
||||
- `Pause`는 recovery tone
|
||||
- `Break`는 release / reset tone
|
||||
- `Return`은 re-entry tone
|
||||
|
||||
### 5. focus ended while away는 Break가 아니라 Return이다
|
||||
|
||||
사용자가 없는 동안 focus가 끝났다면,
|
||||
돌아온 순간의 경험은 `쉬는 중`이 아니라 `복귀 결정`이어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 상태 모델
|
||||
|
||||
VibeRoom의 세션 상태를 제품적으로는 아래처럼 다룬다.
|
||||
|
||||
### Core States
|
||||
|
||||
- `Focus`
|
||||
- `Pause`
|
||||
- `Break`
|
||||
|
||||
### Recovery States
|
||||
|
||||
- `AwayCandidate`
|
||||
- `Return`
|
||||
|
||||
### 의미
|
||||
|
||||
#### Focus
|
||||
|
||||
사용자가 현재 세션 안에서 일하고 있음
|
||||
|
||||
#### Pause
|
||||
|
||||
사용자가 의도적으로 멈춤
|
||||
|
||||
#### Break
|
||||
|
||||
focus 블록을 끝내고 의도적으로 쉬는 상태
|
||||
|
||||
#### AwayCandidate
|
||||
|
||||
사용자가 실제로 자리를 떴을 가능성이 높은 내부 판단 상태
|
||||
|
||||
#### Return
|
||||
|
||||
이탈 후 다시 돌아온 순간, 제품이 복귀를 제안하는 상태
|
||||
|
||||
---
|
||||
|
||||
## 6. 웹에서의 감지 전략
|
||||
|
||||
### v1에서 사용하는 신호
|
||||
|
||||
#### 1. visibility hidden
|
||||
|
||||
- 사용자가 탭을 떠남
|
||||
- 브라우저가 background로 감
|
||||
|
||||
#### 2. pagehide / tab background
|
||||
|
||||
- 페이지가 전환되거나 숨겨짐
|
||||
|
||||
#### 3. sleep / wake 시간 점프
|
||||
|
||||
- 사용자가 기기를 잠그거나 화면이 꺼졌다가 돌아왔을 가능성
|
||||
- `Date.now()` 기준 큰 delta로 감지
|
||||
|
||||
### v1에서 사용하지 않는 신호
|
||||
|
||||
#### blur / focus 단독
|
||||
|
||||
너무 오탐이 많다.
|
||||
|
||||
#### 무입력 시간만으로 Away 판단
|
||||
|
||||
읽고 생각하는 사용자를 잘못 판정할 수 있다.
|
||||
|
||||
#### pointer / keyboard tracking 강제화
|
||||
|
||||
감시처럼 느껴질 수 있어 금지
|
||||
|
||||
---
|
||||
|
||||
## 7. 판단 규칙
|
||||
|
||||
### Rule 1. focus 중 hidden/wake gap 발생
|
||||
|
||||
상태:
|
||||
|
||||
- `Focus` 중
|
||||
- `visibility hidden` 또는 `sleep/wake delta` 발생
|
||||
|
||||
처리:
|
||||
|
||||
- 내부적으로 `AwayCandidate`
|
||||
|
||||
### Rule 2. 돌아왔을 때 focus가 아직 안 끝남
|
||||
|
||||
상태:
|
||||
|
||||
- 사용자가 복귀
|
||||
- 남은 focus 시간이 남아 있음
|
||||
|
||||
처리:
|
||||
|
||||
- `Return` tray 노출
|
||||
- 질문:
|
||||
- `이어서 할까요?`
|
||||
- `한 조각 다시 잡을까요?`
|
||||
|
||||
### Rule 3. 돌아왔을 때 focus가 이미 끝남
|
||||
|
||||
상태:
|
||||
|
||||
- 사용자가 복귀
|
||||
- focus phase는 끝났음
|
||||
- 하지만 사용자는 그 종료를 경험하지 못했음
|
||||
|
||||
처리:
|
||||
|
||||
- `Break` 직접 진입 금지
|
||||
- `Return` tray 노출
|
||||
- 질문:
|
||||
- `자리를 비운 사이 이 블록이 끝났어요. 지금 어떻게 이어갈까요?`
|
||||
|
||||
행동:
|
||||
|
||||
- `지금부터 쉬기`
|
||||
- `다음 목표로 이어가기`
|
||||
- `한 조각 다시 잡기`
|
||||
|
||||
### Rule 4. pause 상태에서 복귀
|
||||
|
||||
상태:
|
||||
|
||||
- 이미 사용자가 직접 `Pause`한 상태
|
||||
|
||||
처리:
|
||||
|
||||
- 이건 Away보다 Pause 흐름이 우선
|
||||
- 기존 pause recovery prompt 유지
|
||||
|
||||
즉, `Away / Return`은 manual pause를 덮어쓰지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 8. Return UX 설계
|
||||
|
||||
### Return의 목적
|
||||
|
||||
- 죄책감 없이 다시 올려놓기
|
||||
- `지금 어떤 상태인지`를 간단히 알려주기
|
||||
- 선택지를 2~3개만 제시하기
|
||||
|
||||
### Return의 기본 구조
|
||||
|
||||
- eyebrow: `다시 돌아왔어요`
|
||||
- 짧은 현재 맥락 설명
|
||||
- primary action 1개
|
||||
- secondary action 1개
|
||||
- tertiary는 최대 1개
|
||||
|
||||
### Case A. focus still running
|
||||
|
||||
카피 예:
|
||||
|
||||
- 제목: `이어서 갈까요?`
|
||||
- 설명: `흐름은 그대로 남아 있어요. 바로 이어가거나 한 조각만 다시 잡을 수 있어요.`
|
||||
|
||||
행동:
|
||||
|
||||
- primary: `이어서 하기`
|
||||
- secondary: `한 조각 다시 잡기`
|
||||
|
||||
### Case B. focus ended while away
|
||||
|
||||
카피 예:
|
||||
|
||||
- 제목: `자리를 비운 사이 이 블록이 끝났어요.`
|
||||
- 설명: `지금부터 쉬거나, 다음으로 이어갈 수 있어요.`
|
||||
|
||||
행동:
|
||||
|
||||
- primary: `지금부터 쉬기`
|
||||
- secondary: `다음 목표 이어가기`
|
||||
- tertiary: `한 조각 다시 잡기`
|
||||
|
||||
### Case C. break ended while away
|
||||
|
||||
이건 v1에서는 단순화한다.
|
||||
|
||||
- break가 끝났고 사용자가 돌아왔다면
|
||||
- `다음 블록으로 이어갈까요?` 정도의 단순 return prompt만 둔다
|
||||
- 이 상태는 후속 slice에서 다룬다
|
||||
|
||||
---
|
||||
|
||||
## 9. Pause / Break / Return의 차이
|
||||
|
||||
### Pause
|
||||
|
||||
- 사용자가 직접 멈춘 상태
|
||||
- 톤: recovery
|
||||
- 질문:
|
||||
- `한 조각 다시 잡기`
|
||||
- `이대로 이어가기`
|
||||
|
||||
### Break
|
||||
|
||||
- 사용자가 focus 블록을 끝낸 뒤 쉬는 상태
|
||||
- 톤: release / reset
|
||||
- 질문:
|
||||
- `쉬기 계속`
|
||||
- `다음으로 가기`
|
||||
|
||||
### Return
|
||||
|
||||
- 사용자가 떠났다가 돌아온 상태
|
||||
- 톤: re-entry
|
||||
- 질문:
|
||||
- `지금 어디서 다시 시작할까?`
|
||||
|
||||
핵심:
|
||||
|
||||
`Pause`와 `Return`은 비슷하지만 다르다.
|
||||
|
||||
- Pause는 사용자가 멈춘 것
|
||||
- Return은 제품이 사용자의 이탈을 감지하고 다시 맞이하는 것
|
||||
|
||||
---
|
||||
|
||||
## 10. UI / Layer 구조
|
||||
|
||||
### Layer 원칙
|
||||
|
||||
- Away / Return도 기존 recovery family 안에 들어간다
|
||||
- 한 번에 하나의 recovery layer만 열 수 있다
|
||||
|
||||
우선순위:
|
||||
|
||||
1. `Complete`
|
||||
2. `Return`
|
||||
3. `Pause`
|
||||
4. `Refocus`
|
||||
5. `Next Beat`
|
||||
|
||||
### 이유
|
||||
|
||||
- Complete는 가장 명시적이고 종료 의미가 강함
|
||||
- Return은 환경 변화에 대한 복귀 진입점
|
||||
- Pause는 사용자의 수동 멈춤
|
||||
- Refocus와 Next Beat는 더 세부적인 recovery 단계
|
||||
|
||||
---
|
||||
|
||||
## 11. 카피 원칙
|
||||
|
||||
### 좋은 톤
|
||||
|
||||
- `다시 돌아왔어요`
|
||||
- `흐름은 그대로 남아 있어요`
|
||||
- `한 조각만 다시 잡을까요?`
|
||||
- `지금부터 쉬거나, 다음으로 이어갈 수 있어요`
|
||||
|
||||
### 금지 톤
|
||||
|
||||
- `집중을 놓쳤네요`
|
||||
- `왜 자리를 비우셨나요`
|
||||
- `세션이 중단되었습니다`
|
||||
- `복귀하세요`
|
||||
|
||||
---
|
||||
|
||||
## 12. 구현 우선순위
|
||||
|
||||
### Slice A. Spec 정리
|
||||
|
||||
- 상태 모델 확정
|
||||
- detection 전략 확정
|
||||
- Return UX 문구 확정
|
||||
|
||||
### Slice B. Detection 도입
|
||||
|
||||
- `visibilitychange`
|
||||
- `pagehide`
|
||||
- sleep/wake delta
|
||||
|
||||
### Slice C. Return Tray 구현
|
||||
|
||||
- focus still running case
|
||||
- focus ended while away case
|
||||
|
||||
### Slice D. Pause / Break 분리 강화
|
||||
|
||||
- copy
|
||||
- visual tone
|
||||
- action hierarchy
|
||||
|
||||
---
|
||||
|
||||
## 13. 측정 지표
|
||||
|
||||
- hidden -> return 이후 resume 비율
|
||||
- away detected 후 abandon 비율
|
||||
- focus ended while away 후 다음 행동 선택률
|
||||
- return tray에서 primary 선택 비율
|
||||
- return 후 2분 이상 유지 비율
|
||||
|
||||
---
|
||||
|
||||
## 14. 이번 기획이 기존 계획과 어떻게 연결되는가
|
||||
|
||||
이 문서는 `중간에 끼어든 기획`이 맞지만, 방향을 흐리는 끼어듦이 아니다.
|
||||
오히려 `Refocus System`을 완성하기 위해 필요한 핵심 보강이다.
|
||||
|
||||
따라서 순서는 이렇게 재정렬한다.
|
||||
|
||||
1. `Refocus System` 구현
|
||||
2. `Away / Return Recovery` 구현
|
||||
3. 그 다음에 originally planned visual polish
|
||||
4. 그 다음에 Break 품질과 Review 확장
|
||||
|
||||
즉, 원래 예정했던 다음 기획은 사라지지 않는다.
|
||||
**Away / Return이 먼저 들어오고, visual polish와 break refinement가 그 뒤로 밀리는 것**이다.
|
||||
|
||||
---
|
||||
|
||||
## 15. 절대 피해야 할 방향
|
||||
|
||||
- 감시처럼 느껴지는 detection
|
||||
- 무입력 시간을 벌점처럼 해석하는 것
|
||||
- hidden -> 자동 pause 강제
|
||||
- 돌아왔을 때 죄책감 유발 카피
|
||||
- focus ended while away인데 그냥 break로 보내는 것
|
||||
|
||||
---
|
||||
|
||||
## 16. 다음 구현에서 꼭 지켜야 할 것
|
||||
|
||||
- detection은 조심스럽게, return UX는 분명하게
|
||||
- pause와 return을 섞지 말 것
|
||||
- break는 reward / reset tone으로 유지할 것
|
||||
- away는 “실패”가 아니라 “복귀가 필요한 순간”으로 다룰 것
|
||||
439
docs/flows/current/15_app_stats_entry_flow_spec.md
Normal file
439
docs/flows/current/15_app_stats_entry_flow_spec.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# 15. `/app -> /stats -> /app` Weekly Review Entry Flow Spec
|
||||
|
||||
Last Updated: 2026-03-15
|
||||
|
||||
이 문서는 VibeRoom의 `Weekly Review`를 **어디서, 왜, 어떤 타이밍에 열어야 하는지**를 정의하는 진입 플로우 문서다.
|
||||
|
||||
핵심 목적은 하나다.
|
||||
|
||||
> `/stats`를 고립된 summary page가 아니라, `/app`의 다음 세션 시작 성공률을 높여주는 review ritual로 코어 루프 안에 넣는다.
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `../../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||
- `../../product/12_core_loop_execution_roadmap.md`
|
||||
- `../../screens/stats/current/14_weekly_review_reframe_spec.md`
|
||||
- `../../product_principles.md`
|
||||
- `../../current_context.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 정의
|
||||
|
||||
현재 구현 기준에서 `/stats`는 route는 존재하지만,
|
||||
제품 루프 안에서의 진입 이유가 약하다.
|
||||
|
||||
현재 문제:
|
||||
|
||||
- `/stats`는 사실상 고립된 페이지다
|
||||
- 사용자가 왜 지금 review를 봐야 하는지 맥락이 없다
|
||||
- `/space`에서 바로 review로 튀면 execution 흐름을 끊는다
|
||||
- `/app`과 `/stats`가 연결되지 않아, review가 다음 세션 시작으로 이어지지 않는다
|
||||
|
||||
즉, 지금 `/stats`는 “있을 수는 있는 페이지”지만
|
||||
**사용자가 자연스럽게 보게 되는 페이지는 아니다.**
|
||||
|
||||
---
|
||||
|
||||
## 2. 한 줄 정의
|
||||
|
||||
> Weekly Review의 primary flow는 `/app`에서 review teaser를 보고, `/stats`에서 지난 7일의 집중 리듬을 짧게 정리한 뒤, 다시 `/app`으로 돌아와 다음 세션을 시작하는 흐름이다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 제품 원칙
|
||||
|
||||
### 1. `/stats`의 primary entry는 `/app`이다
|
||||
|
||||
- review는 “지금 일하는 중에 보는 것”이 아니라
|
||||
- “다음 세션을 시작하기 직전에 확인하는 것”이다
|
||||
|
||||
### 2. `/space`는 review의 메인 진입점이 아니다
|
||||
|
||||
- `/space`는 execution surface다
|
||||
- review는 boundary ritual이어야 한다
|
||||
- `/space`에서는 teaser만 허용하고, full review로의 강한 유도는 금지한다
|
||||
|
||||
### 3. review는 다음 행동으로 연결되어야 한다
|
||||
|
||||
- `/stats`는 읽고 끝나는 페이지가 아니다
|
||||
- 마지막 CTA는 반드시 `/app`의 next session start와 연결돼야 한다
|
||||
|
||||
### 4. review 진입은 “보면 좋음”이 아니라 “지금 보면 의미 있음”이어야 한다
|
||||
|
||||
- 무조건 항상 띄우지 않는다
|
||||
- 데이터가 충분하고, 지금 보는 것이 실제로 의미 있을 때만 노출한다
|
||||
|
||||
### 5. review는 planning이 아니라 focus optimization이다
|
||||
|
||||
- 할 일 정리로 이어지면 실패
|
||||
- 다음 세션의 시작 조건을 더 잘 맞추는 흐름이어야 한다
|
||||
|
||||
---
|
||||
|
||||
## 4. 왜 `/app`이 primary entry인가
|
||||
|
||||
`/app`은 commitment gate다.
|
||||
즉 사용자가 “지금 한 가지를 붙잡고 들어가겠다”는 결정을 내리는 곳이다.
|
||||
|
||||
review가 가장 유의미한 순간도 바로 여기다.
|
||||
|
||||
왜냐하면:
|
||||
|
||||
- review를 보고 바로 다음 세션을 시작할 수 있다
|
||||
- `/space`보다 맥락이 느슨해서 회고가 자연스럽다
|
||||
- `/stats`를 planner page처럼 쓰지 않고 entry ritual로 흡수할 수 있다
|
||||
|
||||
반대로 `/space`는:
|
||||
|
||||
- 이미 실행 중인 화면이고
|
||||
- review가 들어오면 흐름을 끊고
|
||||
- 제품을 dashboard처럼 보이게 만들 수 있다
|
||||
|
||||
결론:
|
||||
|
||||
- `/app`은 review를 여는 문
|
||||
- `/stats`는 주간 review desk
|
||||
- `/app`으로 돌아와 다시 시작
|
||||
|
||||
이 삼각 구조가 가장 맞다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 사용자 플로우
|
||||
|
||||
### Primary Flow
|
||||
|
||||
`/app -> /stats -> /app`
|
||||
|
||||
1. 사용자가 `/app`에 들어온다
|
||||
2. entry stage 바깥의 quiet review dock에 `weekly review entry`가 보인다
|
||||
3. 사용자가 teaser를 누른다
|
||||
4. `/stats`로 이동한다
|
||||
5. review를 본다
|
||||
6. 하단 CTA `이 흐름으로 다음 세션 시작`
|
||||
7. `/app`으로 돌아온다
|
||||
8. review에서 이어온 ritual/context를 가지고 바로 start한다
|
||||
|
||||
핵심:
|
||||
|
||||
- review는 `/app`을 대체하지 않는다
|
||||
- review는 `/app`을 보조한다
|
||||
- review를 보고 다시 `/app`으로 돌아오는 구조가 중요하다
|
||||
|
||||
### Secondary Flow
|
||||
|
||||
`/space complete -> teaser -> /stats`
|
||||
|
||||
1. 사용자가 `/space`에서 세션을 마친다
|
||||
2. complete flow가 끝난다
|
||||
3. 조건이 충족되면 작은 review teaser만 보여준다
|
||||
4. 사용자가 원할 때만 `/stats`로 간다
|
||||
|
||||
중요:
|
||||
|
||||
- `/space`에서는 full review를 메인 행동으로 밀지 않는다
|
||||
- complete tray를 review 유도 UI로 바꾸지 않는다
|
||||
|
||||
### Tertiary Flow
|
||||
|
||||
`profile / secondary nav -> /stats`
|
||||
|
||||
- 다시 review를 보고 싶은 사용자를 위한 명시적 진입
|
||||
- 메인 CTA는 아니다
|
||||
|
||||
---
|
||||
|
||||
## 6. `/app`에서 review teaser를 언제 보여줄 것인가
|
||||
|
||||
항상 보여주면 안 된다.
|
||||
“지금 봐도 의미 없음” 상태에서는 review가 노이즈가 된다.
|
||||
|
||||
### 노출 조건
|
||||
|
||||
기본 규칙:
|
||||
|
||||
- 최근 7일 started session >= 3
|
||||
|
||||
추가 강화 조건:
|
||||
|
||||
- completed session >= 2
|
||||
또는
|
||||
- returned after pause >= 1
|
||||
|
||||
즉,
|
||||
|
||||
- 데이터가 너무 적으면 teaser를 숨긴다
|
||||
- 최소한 “이번 주 리듬”이라고 부를 수 있을 만큼 쌓였을 때만 연다
|
||||
|
||||
### 노출하지 않는 경우
|
||||
|
||||
- 첫 1~2회 사용
|
||||
- 세션 기록이 거의 없는 주
|
||||
- current session이 있고 review 데이터도 거의 없는 상황
|
||||
|
||||
이 경우:
|
||||
|
||||
- `/app` no-session 상태에서는 goal/duration/atmosphere entry stage에 집중
|
||||
- current session이 있으면 `/app` 대신 `/space`로 이동하므로, `/app` review dock은 no-session entry shell에서만 다룬다
|
||||
|
||||
---
|
||||
|
||||
## 7. `/app` review dock 설계
|
||||
|
||||
### 위치
|
||||
|
||||
추천 위치:
|
||||
|
||||
- desktop에서는 entry stage 우측 또는 우상단 quiet dock
|
||||
- mobile에서는 entry stage 아래의 얇은 secondary panel
|
||||
|
||||
금지:
|
||||
|
||||
- entry stage보다 위
|
||||
- entry CTA와 같은 무게
|
||||
- card 3개 중 하나처럼 보이게 배치
|
||||
|
||||
### 형태
|
||||
|
||||
- low-emphasis secondary panel
|
||||
- 작고 조용한 one-liner + secondary CTA
|
||||
|
||||
예시:
|
||||
|
||||
- `이번 주엔 오전 시작에서 가장 오래 이어졌어요`
|
||||
- `pause 뒤 복귀 3회 · review 보기`
|
||||
|
||||
### CTA
|
||||
|
||||
- `주간 review 보기`
|
||||
|
||||
보조 문구:
|
||||
|
||||
- `다음 세션 전에 가볍게 보고 갈 수 있어요`
|
||||
|
||||
### UX 원칙
|
||||
|
||||
- teaser는 insight를 다 말하지 않는다
|
||||
- review를 보고 싶게 만들되, entry stage를 가리지 않는다
|
||||
- start CTA보다 절대 커 보이면 안 된다
|
||||
|
||||
---
|
||||
|
||||
## 8. `/stats` 내부 플로우
|
||||
|
||||
### 진입 직후
|
||||
|
||||
- 이번 주 snapshot hero
|
||||
- start / recovery / completion
|
||||
- carry forward
|
||||
|
||||
### 마지막 CTA
|
||||
|
||||
review의 마지막 CTA는 아래 중 하나여야 한다.
|
||||
|
||||
- `이 흐름으로 다음 세션 시작`
|
||||
- `이 조합으로 /app 돌아가기`
|
||||
|
||||
추천:
|
||||
|
||||
- CTA를 `/app?review=weekly&entryAtmosphereId=...&entryDurationMinutes=...` 같은 방식으로 연결
|
||||
- 최소한 query 또는 client state로 ritual/context를 넘긴다
|
||||
|
||||
중요:
|
||||
|
||||
- `/stats`에서 바로 `/space`로 가지 않는다
|
||||
- `/app`을 다시 거치게 해서 commitment gate를 유지한다
|
||||
|
||||
왜냐하면:
|
||||
|
||||
- review 후에도 goal은 다시 내가 정해야 한다
|
||||
- VibeRoom의 핵심은 single-goal commitment이지, review에서 바로 auto-start가 아니다
|
||||
|
||||
---
|
||||
|
||||
## 9. `/app` 복귀 후 UX
|
||||
|
||||
### 복귀 상태
|
||||
|
||||
`/stats`에서 온 경우 `/app`은 plain 상태가 아니라
|
||||
review-aware state가 된다.
|
||||
|
||||
보여줄 것:
|
||||
|
||||
- “방금 본 review 기준 추천” 한 줄
|
||||
- review carry-forward hint
|
||||
- optional atmosphere / ritual hint
|
||||
|
||||
예시:
|
||||
|
||||
- `이번 주에 가장 잘 맞았던 흐름 · Forest Draft · 50분`
|
||||
- `이번엔 시작을 더 작게 잡아보세요`
|
||||
|
||||
### 유지할 것
|
||||
|
||||
- goal은 여전히 사용자가 직접 입력
|
||||
- goal과 duration은 여전히 사용자가 직접 입력
|
||||
|
||||
자동으로 하지 말 것:
|
||||
|
||||
- goal 자동 채우기
|
||||
- 바로 `/space`로 보냄
|
||||
- todo/list를 다시 불러옴
|
||||
|
||||
즉 review는:
|
||||
|
||||
- 방향을 준다
|
||||
- 하지만 결정을 대신하진 않는다
|
||||
|
||||
---
|
||||
|
||||
## 10. Free / Pro 차이
|
||||
|
||||
### Free
|
||||
|
||||
- `/app` teaser는 1개만
|
||||
- `/stats`에서는 이번 주 snapshot + start / recovery / completion 핵심까지만
|
||||
- return CTA는 generic
|
||||
- `이 흐름으로 다음 세션 시작`
|
||||
|
||||
### Pro
|
||||
|
||||
- teaser가 더 구체적일 수 있다
|
||||
- `Forest Draft · 50분에서 pause 뒤 복귀율이 가장 높았어요`
|
||||
- `/stats` 마지막 CTA가 더 개인화된다
|
||||
- `가장 잘 맞은 atmosphere로 /app 돌아가기`
|
||||
- `/app` 복귀 후 ritual prefill / carry-forward hint가 더 정교하다
|
||||
|
||||
핵심:
|
||||
|
||||
- Pro는 더 많은 review가 아니라
|
||||
- 더 정확한 next-session handoff를 판다
|
||||
|
||||
---
|
||||
|
||||
## 11. UI/UX 원칙
|
||||
|
||||
### `/app` review dock
|
||||
|
||||
- entry stage보다 작음
|
||||
- 한 줄 summary
|
||||
- secondary CTA
|
||||
- glass/card를 크게 쓰지 않음
|
||||
|
||||
### `/stats`
|
||||
|
||||
- calm review desk
|
||||
- 읽고 이해하고, 마지막에 행동 하나를 고르는 구조
|
||||
|
||||
### `/app` return state
|
||||
|
||||
- 다시 시작하는 감각 유지
|
||||
- review를 보고 왔다고 해서 `/app`이 dashboard처럼 바뀌면 안 된다
|
||||
|
||||
---
|
||||
|
||||
## 12. 구현 순서
|
||||
|
||||
### Slice 1. `/app` weekly review dock 추가
|
||||
|
||||
범위:
|
||||
|
||||
- teaser 노출 조건
|
||||
- teaser UI
|
||||
- `/stats` deep link
|
||||
|
||||
완료 기준:
|
||||
|
||||
- `/app`에서 review로 들어가는 primary path가 생긴다
|
||||
|
||||
### Slice 2. `/stats -> /app` return CTA 연결
|
||||
|
||||
범위:
|
||||
|
||||
- `/stats` 마지막 CTA
|
||||
- query/state handoff
|
||||
- `/app` review-aware state
|
||||
|
||||
완료 기준:
|
||||
|
||||
- review가 읽고 끝나는 페이지가 아니라 다음 세션으로 이어진다
|
||||
|
||||
### Slice 3. Pro personalized handoff
|
||||
|
||||
범위:
|
||||
|
||||
- best ritual carry-forward
|
||||
- review origin state 유지
|
||||
- CTA copy personalization
|
||||
|
||||
완료 기준:
|
||||
|
||||
- Pro가 더 자연스럽게 다음 세션으로 이어진다
|
||||
|
||||
### Slice 4. `/space` secondary teaser
|
||||
|
||||
범위:
|
||||
|
||||
- complete 이후 작은 review teaser
|
||||
- full review 강제 이동 금지
|
||||
|
||||
완료 기준:
|
||||
|
||||
- `/space`에서도 review가 존재하지만 execution을 방해하지 않는다
|
||||
|
||||
---
|
||||
|
||||
## 13. 데이터 / 인터페이스 제안
|
||||
|
||||
### `/app` teaser용 데이터
|
||||
|
||||
새 hook 또는 기존 `useFocusStats` 확장:
|
||||
|
||||
- `hasEnoughWeeklyData`
|
||||
- `weeklyTeaserLine`
|
||||
- `reviewHref`
|
||||
|
||||
### `/stats -> /app` handoff
|
||||
|
||||
추천 query:
|
||||
|
||||
- `review=weekly`
|
||||
- `entryAtmosphereId=forest-draft`
|
||||
- `entryDurationMinutes=50`
|
||||
- `carryHint=start-smaller`
|
||||
|
||||
주의:
|
||||
|
||||
- query는 가볍게
|
||||
- 민감한 분석 전체를 싣지 않는다
|
||||
|
||||
### `/app` 복귀 상태
|
||||
|
||||
로컬 상태:
|
||||
|
||||
- `reviewSource`
|
||||
- `suggestedEntryAtmosphereId`
|
||||
- `suggestedEntryDurationMinutes`
|
||||
- `carryForwardHint`
|
||||
|
||||
---
|
||||
|
||||
## 14. 완료 기준
|
||||
|
||||
- `/stats`의 primary entry가 `/app`에 생긴다
|
||||
- `/space`는 review의 secondary entry만 가진다
|
||||
- review를 본 뒤 사용자가 다시 `/app`으로 돌아와 다음 세션을 시작할 수 있다
|
||||
- review가 planner나 dashboard가 아니라 next-session ritual처럼 읽힌다
|
||||
- Free / Pro의 handoff 차이가 분명하다
|
||||
|
||||
---
|
||||
|
||||
## 15. 절대 피해야 할 방향
|
||||
|
||||
- `/space`에서 review로 강하게 끌어가는 것
|
||||
- review를 top nav의 메인 기능처럼 밀어올리는 것
|
||||
- `/stats`에서 바로 `/space` auto-start
|
||||
- review 결과로 goal을 자동 생성하는 것
|
||||
- review teaser가 `/app` hero보다 더 커 보이게 하는 것
|
||||
- planner/todo 회고처럼 보이게 만드는 것
|
||||
156
docs/flows/current/18_paused_session_reentry_spec.md
Normal file
156
docs/flows/current/18_paused_session_reentry_spec.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# 18. Session Routing Spec
|
||||
|
||||
Last Updated: 2026-03-16
|
||||
|
||||
이 문서는 VibeRoom에서 `/app`과 `/space`의 역할을 어떻게 나눌지 정의한다.
|
||||
|
||||
핵심 원칙은 하나다.
|
||||
|
||||
> current session이 있으면 사용자를 `/app`에 세워두지 않고 바로 `/space`로 보낸다.
|
||||
> current session이 없을 때만 `/app`에서 새 entry를 만든다.
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `../../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||
- `../../screens/space/current/13_space_intent_card_collapsed_expanded_spec.md`
|
||||
- `./15_app_stats_entry_flow_spec.md`
|
||||
- `../../product/12_core_loop_execution_roadmap.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 왜 바꾸는가
|
||||
|
||||
이전 구조는 `/app`이
|
||||
|
||||
- no-session entry shell
|
||||
- paused resume gate
|
||||
- takeover decision
|
||||
- review secondary entry
|
||||
|
||||
를 모두 안고 있었다.
|
||||
|
||||
문제:
|
||||
|
||||
- `/app`의 정체성이 흐려진다
|
||||
- 사용자는 `들어가는 화면`인지 `멈춘 세션을 처리하는 화면`인지 헷갈린다
|
||||
- `/space`의 recovery UX와 `/app`의 resume UX가 중복된다
|
||||
|
||||
따라서 `/app`은 다시 단순해져야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 한 줄 정의
|
||||
|
||||
### `/app`
|
||||
|
||||
새 session을 시작하기 위한 atmosphere entry surface
|
||||
|
||||
### `/space`
|
||||
|
||||
이미 존재하는 session을 계속 다루는 execution surface
|
||||
|
||||
---
|
||||
|
||||
## 3. 라우팅 규칙
|
||||
|
||||
### Rule A. current session이 있으면 `/space`
|
||||
|
||||
상태:
|
||||
|
||||
- `currentSession` 존재
|
||||
- `state = running` 또는 `paused`
|
||||
- `phase = focus` 또는 `break`
|
||||
|
||||
처리:
|
||||
|
||||
- `/app` 진입 시 즉시 `/space`
|
||||
|
||||
이유:
|
||||
|
||||
- session이 살아 있는 동안 사용자의 일은 이미 시작된 상태다
|
||||
- `/app`이 끼어들면 execution surface와 decision surface가 섞인다
|
||||
|
||||
### Rule B. current session이 없으면 `/app`
|
||||
|
||||
상태:
|
||||
|
||||
- current session 없음
|
||||
|
||||
처리:
|
||||
|
||||
- `/app` no-session entry shell 노출
|
||||
|
||||
---
|
||||
|
||||
## 4. `/app`의 역할
|
||||
|
||||
`/app`은 아래 3가지 결정만 받는다.
|
||||
|
||||
1. goal
|
||||
2. duration
|
||||
3. atmosphere
|
||||
|
||||
포함하지 않음:
|
||||
|
||||
- paused resume gate
|
||||
- takeover sheet
|
||||
- current session review entry
|
||||
- running / paused 상태별 CTA
|
||||
|
||||
---
|
||||
|
||||
## 5. `/space`의 역할
|
||||
|
||||
current session이 있는 동안의 모든 판단은 `/space`에서 이뤄진다.
|
||||
|
||||
예:
|
||||
|
||||
- 이어가기
|
||||
- 잠시 멈춤
|
||||
- 다시 붙잡기
|
||||
- 다음 단계 정하기
|
||||
- 여기서 마무리하기
|
||||
|
||||
즉 paused session도 `/space` 안에서 다시 다룬다.
|
||||
|
||||
---
|
||||
|
||||
## 6. Weekly Review entry 규칙
|
||||
|
||||
### `/app`
|
||||
|
||||
- current session이 없을 때만 quiet secondary entry로 노출
|
||||
|
||||
### `/space`
|
||||
|
||||
- complete 이후 setup 상태에서만 secondary teaser 허용
|
||||
|
||||
current session이 살아 있는 동안 `/app`에서 review를 여는 flow는 current가 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 구현 규칙
|
||||
|
||||
1. `/app`은 current session fetch 후 session이 있으면 바로 `/space` redirect
|
||||
2. `/app` render tree에는 paused gate / takeover sheet를 남기지 않는다
|
||||
3. `/space`는 paused session이라고 `/app`으로 되돌리지 않는다
|
||||
4. `/space` recovery는 pause / return / break / complete 안에서 닫는다
|
||||
|
||||
---
|
||||
|
||||
## 8. 하지 말아야 할 것
|
||||
|
||||
- paused session만 `/app`에 남기기
|
||||
- `/app -> /space -> 다시 resume/start` 이중 결정
|
||||
- `/app`에서 current session goal을 편집하기
|
||||
- current session이 있는데 `/app`에서 새 entry를 겹쳐 띄우기
|
||||
|
||||
---
|
||||
|
||||
## 9. QA 포인트
|
||||
|
||||
1. current session 없음 -> `/app` entry shell
|
||||
2. current session running -> `/app` 진입 즉시 `/space`
|
||||
3. current session paused -> `/app` 진입 즉시 `/space`
|
||||
4. `/space`에서 pause 후 화면이 `/app`으로 튀지 않음
|
||||
5. `/space` complete 후 no-session이 되면 다시 `/app` entry shell 접근 가능
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
## 프로젝트 목적
|
||||
|
||||
VibeRoom Web은 몰입 공간 경험을 빠르게 실험하기 위한 프론트엔드 목업 프로젝트다.
|
||||
핵심 목표는 실제 기능 완성보다 UX 흐름, 화면 구조, 상호작용 톤을 안정적으로 검증하는 것이다.
|
||||
VibeRoom Web은 ADHD와 프리랜서를 위한 premium focus service의 웹 제품을 설계하고 구현하는 프로젝트다.
|
||||
핵심 목표는 UX 흐름, 화면 구조, 상호작용 톤, core loop 계약을 실제 제품 품질로 안정적으로 끌어올리는 것이다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
- Next.js (App Router)
|
||||
- TypeScript
|
||||
- TailwindCSS
|
||||
- 상태: React state + 일부 Zustand
|
||||
- 상태: React state + local storage + 일부 shared store
|
||||
|
||||
## 유지보수 역할 정의
|
||||
|
||||
@@ -19,22 +19,22 @@ VibeRoom Web은 몰입 공간 경험을 빠르게 실험하기 위한 프론트
|
||||
- FSD 구조를 지키며 화면/기능을 지속적으로 리팩터링한다.
|
||||
- View 계층을 조합 중심으로 유지하고 로직이 새지 않게 막는다.
|
||||
- 감성/저자극 톤을 유지하며 과한 강조 UI를 억제한다.
|
||||
- 실제 서비스 로직은 구현하지 않고, 더미 데이터와 토스트로 흐름만 검증한다.
|
||||
- mock UI만 만드는 데 그치지 않고, core focus/session/review 흐름은 실제 계약과 연결한다.
|
||||
|
||||
## 범위와 비범위
|
||||
|
||||
범위:
|
||||
|
||||
- 라우트/위젯/피처 단위 UI 개선
|
||||
- 더미 데이터 기반 상태 표현
|
||||
- 더미 데이터 + 실제 계약 혼합 상태 표현
|
||||
- 모달, 토글, 탭, 선택, 토스트
|
||||
|
||||
비범위:
|
||||
|
||||
- 실시간 인원수/presence 정확도 보장
|
||||
- 타이머 카운트다운 실제 동작
|
||||
- 오디오 재생 엔진
|
||||
- 서버/DB/API 연동 완성
|
||||
- 오디오 DSP 수준의 고급 엔진
|
||||
- 운영 observability 완성
|
||||
- 모든 주변 기능의 production hardening
|
||||
|
||||
## Definition of Done
|
||||
|
||||
@@ -9,7 +9,6 @@ src/
|
||||
features/ # 사용자 액션/유즈케이스 단위
|
||||
entities/ # 도메인 타입/더미 데이터
|
||||
shared/ # 공용 UI/유틸
|
||||
store/ # 전역 상태(필요 최소)
|
||||
```
|
||||
|
||||
## 핵심 규칙
|
||||
@@ -19,6 +18,7 @@ src/
|
||||
3. UI 상태(토글/선택)만 컴포넌트 내부에서 최소 허용한다.
|
||||
4. 파일 길이 500줄 이상이면 즉시 분리한다.
|
||||
5. 하위 레이어가 상위 레이어를 import하지 않는다.
|
||||
6. 전역 상태가 필요하면 먼저 해당 도메인 slice의 `model/` 안에 둔다.
|
||||
|
||||
## Import 방향 규칙
|
||||
|
||||
@@ -33,12 +33,14 @@ src/
|
||||
- `features` -> 다른 `features` 직접 참조 (강한 결합 유발)
|
||||
- `shared` -> `entities/features/widgets/app` 참조
|
||||
- `page.tsx`에 도메인 로직/세부 UI 구현 누적
|
||||
- 루트 전역 저장소를 관성적으로 추가하는 것
|
||||
|
||||
## 구현 정책 (이 프로젝트 전용)
|
||||
|
||||
- 실제 타이머/오디오/서버/DB 기능은 구현하지 않는다.
|
||||
- 기능 트리거는 토스트 또는 더미 상태 전환으로 표현한다.
|
||||
- 도메인 표시는 `entities` 데이터에서 읽고 뷰 하드코딩을 지양한다.
|
||||
- 인증/세션 같은 전역 상태도 가능하면 해당 도메인 `entities/*/model` 안에서 관리한다.
|
||||
|
||||
## 파일 분리 기준
|
||||
|
||||
@@ -14,21 +14,24 @@
|
||||
### `/app` (허브)
|
||||
|
||||
- Page: `src/app/(app)/app/page.tsx`
|
||||
- Core Widget: `src/widgets/app-hub/ui/AppHubWidget.tsx`
|
||||
- Core Widget: `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`
|
||||
- 주요 구성:
|
||||
- `StartRitualWidget`
|
||||
- `RoomsGalleryWidget`
|
||||
- `CustomEntryWidget`
|
||||
- `FocusDashboardWidget`
|
||||
- paused `Resume Gate`
|
||||
- no-session `Atmosphere Entry Shell` (기획 기준, 구현 예정)
|
||||
- 데이터 소스:
|
||||
- room 목록: `entities/room`
|
||||
- 목표/타이머/사운드 프리셋: `entities/session`
|
||||
- current session: `features/focus-session`
|
||||
- weekly review: `features/stats`
|
||||
- atmosphere 선택 데이터: entry slice 구현 예정
|
||||
|
||||
### `/space` (집중 화면)
|
||||
|
||||
- Page: `src/app/(app)/space/page.tsx`
|
||||
- Core Widget: `src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx`
|
||||
- Core Widget: `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`
|
||||
- 주요 구성:
|
||||
- `SpaceTimerHudWidget`
|
||||
- `SpaceFocusHudWidget`
|
||||
- `SpaceSetupDrawerWidget`
|
||||
- `SpaceToolsDockWidget`
|
||||
- `features/restart-30s` (HUD 내 조합)
|
||||
|
||||
@@ -38,9 +41,11 @@
|
||||
- `sound`: 사운드 preset id
|
||||
- `timer`: 타이머 라벨
|
||||
- `goal`: 목표 한 줄 (선택)
|
||||
- `resume`: `continue | refocus`
|
||||
|
||||
## 변경 시 체크포인트
|
||||
|
||||
- 라우팅 변경 시 `/app -> /space` 진입 흐름이 깨지지 않는지 확인
|
||||
- `running -> /space`, `paused -> /app` 재진입 정책 유지
|
||||
- query param 기본값 처리 유지
|
||||
- page 파일에 로직 누수 여부 확인
|
||||
136
docs/foundation/08_premium_uiux_guideline.md
Normal file
136
docs/foundation/08_premium_uiux_guideline.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Premium Immersive UI/UX Guidelines (VibeRoom Core)
|
||||
|
||||
이 문서는 VibeRoom 프로젝트가 LifeAt, Portal, Focusmate 등 **세계 최고급의 프리미엄 UI/UX 경험**을 일관되게 유지하기 위해 작성된 절대적인 디자인 헌장입니다.
|
||||
어떤 에이전트, 어떤 개발자가 코드를 작성하든 화면을 추가하거나 수정할 때 이 가이드라인을 반드시 숙지하고 엄격하게 준수해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 핵심 철학 (Core Philosophy)
|
||||
|
||||
### 1-0. View Reuse First (사용자 뷰 우선 재사용)
|
||||
- **절대 원칙:** 사용자가 직접 다듬어 둔 현재 화면의 view, spacing, motion, hierarchy는 곧 source of truth입니다.
|
||||
- AI 에이전트나 개발자는 UI 작업 시 새로운 shell, 새로운 visual language, 새로운 레이아웃을 임의로 다시 그리면 안 됩니다.
|
||||
- 기능 추가/상태 분기 변경이 필요해도 **기존 view를 재사용하고, 그 내부에 stage/state만 확장**하는 방식으로 처리합니다.
|
||||
- "더 좋아 보인다", "더 premium해 보인다"는 이유만으로 현재 사용 중인 view를 갈아엎는 것은 금지합니다.
|
||||
- 새 화면이 필요할 때도 먼저 기존 화면의 shell, typography, spacing, animation을 복제/재사용할 수 있는지부터 검토합니다.
|
||||
- 특히 `/space`처럼 사용자가 직접 premium tone을 반복적으로 조정한 화면에서는, **현재 구현된 컴포넌트가 디자인 시스템 그 자체**라고 보고 수정 범위를 최소화해야 합니다.
|
||||
|
||||
### 1-1. 무대 우선주의 (Stage-first & Immersive)
|
||||
- **절대 원칙:** 사용자가 선택한 **배경(Atmosphere/Scene) 자체가 곧 앱의 정체성이자 무대**입니다.
|
||||
- 배경을 가리거나 시야를 방해하는 거대한 대시보드 형태의 레이아웃(Split-screen, 거대한 Solid Card Grid)은 절대 금지합니다.
|
||||
- UI는 무대 위에 떠 있는 얇고 투명한 유리 조각(Glass)처럼 존재해야 하며, 화면의 여백(White Space)을 극대화하여 공간감을 제공해야 합니다.
|
||||
|
||||
### 1-2. 중앙 집중의 의식 (Minimal Central Ritual)
|
||||
- 사용자가 수행해야 할 가장 중요한 단 하나의 핵심 액션(예: "무엇에 집중할 것인가?")은 **항상 화면의 정중앙에 거대하고 우아하게 배치**합니다.
|
||||
- 텍스트 입력창은 투명하게(`bg-transparent`), 폰트는 크고 얇게(`text-4xl font-light tracking-tight`) 유지하여 단순한 '입력'이 아닌 '의식(Ritual)'처럼 느껴지게 합니다.
|
||||
|
||||
### 1-3. 압도적인 글래스모피즘 (Premium Glassmorphism)
|
||||
- 단순히 투명도를 낮추는 것이 아닙니다. 뒤의 빛과 배경이 은은하게 굴절되는 진짜 유리의 질감을 구현해야 합니다.
|
||||
- **필수 속성:** `backdrop-blur-xl` 또는 `backdrop-blur-2xl`을 반드시 사용합니다.
|
||||
- **테두리와 명암:** 투박한 solid border 대신, `border-white/5` ~ `border-white/10` 수준의 매우 얇고 투명한 테두리를 사용합니다. 깊이감을 위해 다중 그림자(`shadow-2xl` 등)와 미세한 그라데이션(`bg-[linear-gradient(...)]`)을 조합합니다.
|
||||
|
||||
### 1-4. 무대와 대기실의 분리 (Stage vs. Lobby Separation)
|
||||
- **세션(Space) 외의 화면(`/app`, `/stats`, `/settings` 등)은 실제 집중 배경(Atmosphere)을 그대로 띄우지 않습니다.**
|
||||
- 대기실(Lobby) 역할을 하는 화면에 너무 구체적인 풍경이나 영상이 띄워져 있으면, 사용자가 이미 집중 세션에 들어왔다고 착각하거나 인지적 피로감을 느낄 수 있습니다.
|
||||
- **해결책:** 대기실 화면의 배경은 집중할 때 볼 풍경을 블러 처리(`blur-3xl`)하거나, 매우 어둡고 깊이 있는 추상적 그라데이션(예: `bg-black`에 은은한 틴트)으로 처리하여 **"아직 무대에 오르기 전(또는 내려온 후)"** 이라는 심리적 분리감을 명확히 주어야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 레이아웃 & 컴포넌트 배치 원칙 (Layout & Placement)
|
||||
|
||||
### 2-1. 절대 위치(Absolute) 사용의 엄격한 제한
|
||||
- UI 요소가 창 크기 조절(Resizing) 시 중앙의 핵심 컨텐츠(Main Ritual)를 가리거나 겹치는 현상(Overlap)은 치명적인 결함입니다.
|
||||
- **해결책:** 좌/우측에 둥둥 떠 있는(Floating) 위젯 형태를 구현할 때, 단순히 `absolute top-x left-y`로 띄워두지 마십시오. 창이 작아져도 겹치지 않게 하려면 중앙 컨테이너의 흐름(Inline Flex) 내부에 배치하거나, 화면 크기에 따른 철저한 미디어 쿼리 제어를 통해 **어떤 해상도에서도 메인 텍스트 영역을 침범하지 않도록 보장**해야 합니다.
|
||||
|
||||
### 2-2. 보조 위젯의 극단적 미니멀리즘 (Subtle Accessories)
|
||||
- 메인 액션이 아닌 모든 정보(예: Weekly Review, Error Message, 힌트 등)는 **크기를 최소화하고 시각적 대비를 낮춥니다.**
|
||||
- 정보 텍스트는 `text-[12px]` 또는 `text-[13px]`, 라벨이나 뱃지는 `text-[9px] ~ text-[10px]`에 두꺼운 자간(`tracking-[0.25em]`)과 대문자(`uppercase`) 조합을 사용하여 명품 브랜드의 타이포그래피처럼 디자인합니다.
|
||||
- 예: 투박한 'Weekly Review' 카드 ❌ -> 한 줄의 세련된 'Smart Hint Pill' 형태 ⭕
|
||||
|
||||
### 2-3. 가장자리 도킹 (Edge Docking)
|
||||
- 선택형 리스트(Atmosphere 선택 등)는 화면을 덮는 Grid 대신 **화면 최하단에 스와이프 가능한 가로 독(Carousel Dock)** 형태로 배치합니다.
|
||||
- 독 내부의 아이템이 확대(Scale)되거나 애니메이션 될 때, 스크롤 컨테이너의 영역이 좁아 카드가 잘려 보이는 현상(Clipping)이 발생하지 않도록 **컨테이너 자체에 충분한 상하 여백(`py-8` 등)**을 확보해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 애니메이션 및 상호작용 (Motion & Interaction)
|
||||
|
||||
### 3-1. 부드럽고 웅장한 진입 (Stately Entrance)
|
||||
- 뚝 떨어지거나 딱딱하게 나타나는 화면 전환은 금지합니다.
|
||||
- 프리미엄 서비스 특유의 '서서히 떠오르는' 모션을 위해 커스텀 Keyframe(`fade-in-up`, `fade-in`)과 섬세한 이징 커브(`cubic-bezier(0.16, 1, 0.3, 1)`)를 적용합니다.
|
||||
- `animation-delay`를 활용하여 헤더 -> 중앙 텍스트 -> 하단 독 순서로 물결치듯 순차적으로 나타나는 시퀀스를 구성합니다.
|
||||
|
||||
### 3-2. Hover 및 포커스 상태 (Fluid Feedback)
|
||||
- 카드 호버 시 단순히 색만 변하는 것이 아니라, 부드러운 스케일 업(`hover:scale-105 ~ 110`)과 함께 그림자가 깊어지고(`hover:shadow-2xl`) 내부 텍스트 및 오버레이의 명도가 미세하게 조절되어야 합니다.
|
||||
- 버튼의 경우 누를 때 미세하게 작아지는 햅틱 피드백(`active:scale-[0.98]`)을 적용하여 쫀득한 터치감을 줍니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 타이포그래피 및 카피 (Typography & Copy)
|
||||
|
||||
### 4-1. 소음 줄이기 (Reduce Visual Noise)
|
||||
- 중요하지 않은 부가 설명(Helper Text)은 `text-white/40` ~ `text-white/60` 정도로 투명도를 과감히 낮춰 시야에서 멀어지게 합니다.
|
||||
- 강렬한 Primary Color(파란색, 빨간색 등)는 에러나 꼭 필요한 CTA에만 극도로 제한적으로 사용하고, 기본적으로는 **무채색(흰색, 검은색)의 투명도 조절만으로 위계를 표현**합니다.
|
||||
|
||||
### 4-2. 프리미엄 카피라이팅 (Tone & Manner)
|
||||
- 기능적인 설명보다 감성적이고 몰입을 돕는 문구를 사용합니다.
|
||||
- "시간을 입력하세요" ❌ -> "What will you focus on?" / "의식을 시작합니다" ⭕
|
||||
- 업그레이드 등 상업적 CTA도 "결제하기"보다 작고 섬세한 캡슐 버튼(`Upgrade →`)으로 디자인하여 브랜드의 우아함을 지킵니다.
|
||||
|
||||
## 5. AI 에이전트를 위한 Tailwind CSS 프롬프팅 & 코드 패턴 (Crucial for AI)
|
||||
|
||||
AI 에이전트(Codex, Cursor, Cline 등)는 "프리미엄하게 만들어줘"라는 추상적인 지시를 이해하지 못하고 평범한 UI(`bg-gray-100 rounded-md` 등)를 생성하는 경향이 있습니다. AI에게 작업을 지시할 때는 아래의 **명확한 Tailwind 유틸리티 패턴(Snippet)** 을 그대로 복사해서 사용하라고 지시해야 합니다.
|
||||
|
||||
### 5-1. Floating Smart Pill (보조 정보 위젯)
|
||||
AI가 투박한 Card를 만들지 못하게 하고, 이 코드를 복사하라고 지시하세요.
|
||||
```tsx
|
||||
// DO: 아주 작고 은은하게 떠 있는 스마트 필
|
||||
<div className="inline-flex items-center gap-3 rounded-full border border-white/5 bg-white/5 py-2 pl-4 pr-3 backdrop-blur-md transition-all hover:bg-white/10 hover:border-white/10">
|
||||
<span className="flex h-1.5 w-1.5 rounded-full bg-white/40 group-hover:bg-white/60 transition-colors" />
|
||||
<span className="text-[13px] font-medium text-white/70 group-hover:text-white/90">
|
||||
내용 텍스트
|
||||
</span>
|
||||
</div>
|
||||
|
||||
// DON'T: AI가 자주 실수하는 투박한 솔리드 카드
|
||||
<div className="bg-white rounded-lg p-4 shadow-md">내용 텍스트</div>
|
||||
```
|
||||
|
||||
### 5-2. 메인 입력창 (Ritual Input)
|
||||
AI가 흔한 폼 인풋(`border rounded px-4`)을 만들지 못하게 하세요.
|
||||
```tsx
|
||||
// DO: 거대하고 얇고 투명한 의식적 텍스트
|
||||
<input
|
||||
className="w-full bg-transparent text-center text-4xl font-light tracking-tight text-white outline-none placeholder:text-white/20 md:text-5xl lg:text-[4.5rem] lg:leading-[1.1]"
|
||||
/>
|
||||
|
||||
// DON'T: 일반적인 대시보드 폼
|
||||
<input className="border border-gray-300 rounded px-4 py-2 w-full" />
|
||||
```
|
||||
|
||||
### 5-3. 프리미엄 버튼 (Primary Action)
|
||||
```tsx
|
||||
// DO: 빛나는 투명 테두리와 쫀득한 햅틱 모션을 가진 버튼
|
||||
<button className="group relative flex h-16 items-center justify-center overflow-hidden rounded-full border border-white/20 bg-white/10 px-12 text-lg font-medium tracking-wide text-white shadow-2xl backdrop-blur-md transition-all duration-300 hover:bg-white/20 hover:scale-[1.02] active:scale-[0.98]">
|
||||
시작하기
|
||||
</button>
|
||||
```
|
||||
|
||||
### 5-4. 그라데이션 오버레이 (배경 어둡게 하기)
|
||||
이미지 위에서 글씨가 잘 보이게 하려면 단순 `bg-black/50` 대신 깊이감 있는 그라데이션을 써야 합니다.
|
||||
```tsx
|
||||
// DO: 복합 그라데이션 (중앙은 비우고 테두리만 어둡게)
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.6)_100%)] mix-blend-multiply pointer-events-none" />
|
||||
<div className="absolute inset-0 bg-black/10 pointer-events-none" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. QA 체크리스트 (커밋 전 반드시 확인)
|
||||
|
||||
1. **Overlap Check:** 브라우저 창을 최소 크기(모바일/작은 데스크탑)로 줄였을 때 위젯이나 텍스트가 겹치거나 잘리는 곳이 단 한 곳이라도 있는가? (있다면 즉시 수정)
|
||||
2. **Clipping Check:** 리스트 내의 아이템에 마우스를 올렸을 때(hover:scale) 아이템의 상하좌우 테두리가 부모 컨테이너에 의해 잘려 나가는 현상이 없는가?
|
||||
3. **Hierarchy Check:** 화면 내에서 가장 눈에 띄는 것이 "현재 사용자가 해야 할 단 하나의 액션"인가? 부가 정보가 너무 커서 메인 액션을 압도하지 않는가?
|
||||
4. **Motion Check:** 화면 진입 시 모든 요소가 우아하고 부드럽게 등장하는가? 깜빡이거나 투박하게 나타나는 요소는 없는가?
|
||||
5. **Glass Check:** 모든 팝오버, 시트, 위젯이 뒷 배경을 우아하게 투영(backdrop-blur)하고 있으며, 테두리가 지나치게 두껍지 않은가?
|
||||
6. **Reuse Check:** 이번 작업이 기존 사용자가 만든 view를 재사용/확장한 것인가? 새 shell이나 새 visual language를 임의로 만들지 않았는가?
|
||||
@@ -4,9 +4,13 @@ Codex CLI가 중단되거나 컨텍스트가 초기화된 뒤 재개할 때 사
|
||||
|
||||
## 재개 시작 (5분)
|
||||
|
||||
1. `docs/README.md`에서 우선 읽기 5개 문서를 순서대로 확인
|
||||
1. `docs/README.md`에서 지금 작업할 화면 섹션과 우선 읽기 문서를 확인
|
||||
2. `git status --short`로 작업 트리 상태 파악
|
||||
3. `docs/90_current_state.md`의 `NEXT` 1순위부터 착수
|
||||
3. `docs/work.md`와 `docs/90_current_state.md`의 현재 우선순위를 확인
|
||||
4. 화면 작업이면 해당 source-of-truth spec을 추가로 읽는다
|
||||
- `/app` -> `19`, `18`, `15`
|
||||
- `/space` -> `10`, `11`, `13`
|
||||
- `/stats` -> `14`, `15`
|
||||
|
||||
## 구현 중 체크
|
||||
|
||||
@@ -28,8 +28,8 @@ docs(session): 세션 복구 워크플로우와 커밋 템플릿 추가
|
||||
- codex cli 중단 시 작업 맥락 손실을 줄이기 위해
|
||||
|
||||
변경사항:
|
||||
- docs/06_commit_convention.md 추가
|
||||
- docs/07_session_recovery.md 추가
|
||||
- docs/foundation/06_commit_convention.md 추가
|
||||
- docs/ops/07_session_recovery.md 추가
|
||||
- scripts/session/recover-context.sh 추가
|
||||
|
||||
검증:
|
||||
@@ -19,12 +19,12 @@
|
||||
|
||||
### 조건부 로드 (필요할 때만)
|
||||
|
||||
- UI/카피/CTA 변경이 있으면 `docs/01_ui_guidelines.md`
|
||||
- 구조/FSD/레이어 변경이 있으면 `docs/02_arch_fsd_rules.md`
|
||||
- 커밋 직전에 `docs/06_commit_convention.md`
|
||||
- UI/카피/CTA 변경이 있으면 `docs/foundation/01_ui_guidelines.md`
|
||||
- 구조/FSD/레이어 변경이 있으면 `docs/foundation/02_arch_fsd_rules.md`
|
||||
- 커밋 직전에 `docs/foundation/06_commit_convention.md`
|
||||
- 현재 상태 상세가 필요하면 `docs/90_current_state.md`
|
||||
- 핵심 규칙 상세가 필요하면 `docs/context_core.md`
|
||||
- 규칙 충돌/모호함이 있으면 `docs/00_project_brief.md`, `docs/04_coding_rules.md`
|
||||
- 규칙 충돌/모호함이 있으면 `docs/foundation/00_project_brief.md`, `docs/foundation/04_coding_rules.md`
|
||||
|
||||
## 2) 구현 원칙 (강제)
|
||||
|
||||
433
docs/product/12_core_loop_execution_roadmap.md
Normal file
433
docs/product/12_core_loop_execution_roadmap.md
Normal file
@@ -0,0 +1,433 @@
|
||||
# 12. Core Loop Execution Roadmap
|
||||
|
||||
Last Updated: 2026-03-15
|
||||
|
||||
이 문서는 VibeRoom의 핵심 제품 기획을 **어떤 순서로 구현까지 연결할지**를 정의하는 실행 로드맵이다.
|
||||
|
||||
중요한 목적은 두 가지다.
|
||||
|
||||
- 중간에 끼어드는 기획이 전체 방향을 흔들지 않게 한다
|
||||
- 어떤 세션, 어떤 에이전트가 들어와도 **다음으로 무엇을 해야 하는지** 즉시 이해하게 한다
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `../../product_principles.md`
|
||||
- `../../current_context.md`
|
||||
- `../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||
- `../flows/current/10_refocus_system_spec.md`
|
||||
- `../flows/current/11_away_return_recovery_spec.md`
|
||||
- `../flows/current/15_app_stats_entry_flow_spec.md`
|
||||
- `../flows/current/18_paused_session_reentry_spec.md`
|
||||
- `./16_product_alignment_audit_plan.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 기본 원칙
|
||||
|
||||
VibeRoom은 아래 방식으로 진행한다.
|
||||
|
||||
1. 바뀌면 안 되는 제품 원칙을 먼저 고정한다
|
||||
2. 코어 루프를 정의한다
|
||||
3. 코어 루프를 **slice 단위**로 상세 기획한다
|
||||
4. 각 slice는 `기획 -> 구현 -> 브라우저 QA -> 문서 업데이트`까지 닫고 간다
|
||||
5. 다음 slice는 이전 slice가 제품적으로 안정된 뒤에 들어간다
|
||||
|
||||
즉:
|
||||
|
||||
- `모든 기획을 끝낸 뒤 한 번에 개발`하지 않는다
|
||||
- `생각나는 기능을 바로 구현`하지도 않는다
|
||||
- **큰 방향은 먼저 고정하고, 세부는 vertical slice로 기획 후 바로 구현**한다
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재까지 완료된 것
|
||||
|
||||
### 완료 1. Product Principles
|
||||
|
||||
- 루트 문서 `../../product_principles.md`
|
||||
- single-goal, premium focus, anti-to-do 방향 고정
|
||||
|
||||
### 완료 2. `/app` Entry Reframe (Legacy)
|
||||
|
||||
- 문서: historical, superseded by `../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||
- 구현 상태:
|
||||
- `/app`은 한때 single-goal commitment gate로 정리됐음
|
||||
- planner/list-first 구조 제거
|
||||
- current session이 있으면 resume 우선
|
||||
|
||||
### 다음 Phase A. `/app` Atmosphere Entry Redesign
|
||||
|
||||
- 문서: `../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||
- 목적:
|
||||
- `/app` no-session 상태를 `goal + duration + atmosphere` 중심의 premium entry stage로 재설계
|
||||
- scene/sound를 다시 entry 가치로 끌어올리되 planner/dashboard처럼 보이지 않게 유지
|
||||
- 핵심 변화:
|
||||
- microStep 제거
|
||||
- duration을 분 단위 직접 입력으로 전환
|
||||
- `Atmosphere` 12개 그리드 도입
|
||||
- weekly review는 quiet secondary dock로 유지
|
||||
- 상태:
|
||||
- 상세 기획 완료
|
||||
- 구현 전
|
||||
|
||||
### 완료 3. Refocus System 기본 구조
|
||||
|
||||
- 문서: `../flows/current/10_refocus_system_spec.md`
|
||||
- 구현 상태:
|
||||
- pause -> refocus 흐름의 기본 skeleton 존재
|
||||
- next beat / goal complete의 상태 분리 시작
|
||||
|
||||
### 완료 4. Away / Return Recovery 기본 구현
|
||||
|
||||
- 문서: `../flows/current/11_away_return_recovery_spec.md`
|
||||
- 구현 상태:
|
||||
- `visibilitychange`, `pagehide`, sleep/wake gap 기반 detection 연결
|
||||
- focus running 복귀 시 `Return` tray 노출
|
||||
- focus ended while away 시 standard break 대신 `Return` tray 선행
|
||||
|
||||
### 완료 5. Pause / Break / Return 1차 톤 분리
|
||||
|
||||
- 구현 상태:
|
||||
- pause tray 가독성 재정리
|
||||
- `Return(break)`와 `Return(focus)` material 분리 시작
|
||||
- break 관련 선택과 timer HUD를 더 release tone으로 보정
|
||||
|
||||
---
|
||||
|
||||
## 3. 지금 끼어든 기획의 위치
|
||||
|
||||
### 끼어든 기획
|
||||
|
||||
- `../flows/current/11_away_return_recovery_spec.md`
|
||||
|
||||
이 기획은 원래 흐름을 덮어쓴 것이 아니다.
|
||||
이 문서는 **Refocus System을 실제 사용자 행동에 맞게 완성하기 위해 중간에 추가된 필수 slice**다.
|
||||
|
||||
왜 끼어들었는가:
|
||||
|
||||
- 사용자는 `pause`를 누르지 않고 자리를 뜨는 경우가 많다
|
||||
- 이 케이스를 다루지 않으면 Refocus System이 반쪽짜리가 된다
|
||||
- `pause`와 `break`가 겹쳐 보이는 문제도 여기서 정리해야 한다
|
||||
|
||||
즉, 이 기획은 “옆길”이 아니라 **Refocus System의 확장 파트**다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 현재 기준의 구현 순서
|
||||
|
||||
아래 순서를 공식 순서로 고정한다.
|
||||
|
||||
### Phase 1. `/app` Entry Polish
|
||||
|
||||
목적:
|
||||
|
||||
- 시작 마찰을 최소화
|
||||
- `/app`이 planner가 아니라 commitment gate로 읽히게 고정
|
||||
|
||||
상태:
|
||||
|
||||
- 기획 완료
|
||||
- 구현 완료
|
||||
- 남은 것은 브라우저 QA와 minor polish
|
||||
|
||||
### Phase 2. Refocus System
|
||||
|
||||
문서:
|
||||
|
||||
- `../flows/current/10_refocus_system_spec.md`
|
||||
|
||||
목적:
|
||||
|
||||
- pause 이후 복귀
|
||||
- microStep 완료 후 다음 한 조각 연결
|
||||
- goal complete의 자연스러운 closure
|
||||
|
||||
상태:
|
||||
|
||||
- 기획 완료
|
||||
- 핵심 구현 완료
|
||||
- 남은 것은 visual polish와 browser QA
|
||||
|
||||
### Phase 3. Away / Return Recovery
|
||||
|
||||
문서:
|
||||
|
||||
- `../flows/current/11_away_return_recovery_spec.md`
|
||||
|
||||
목적:
|
||||
|
||||
- 사용자가 `pause` 없이 떠난 경우를 감지
|
||||
- 돌아왔을 때 Break가 아니라 Return UX로 맞이
|
||||
- `pause`, `break`, `return`의 감정적 의미를 분리
|
||||
|
||||
상태:
|
||||
|
||||
- 기획 완료
|
||||
- 핵심 구현 완료
|
||||
- 남은 것은 오탐/복귀 타이밍 브라우저 QA
|
||||
|
||||
중요:
|
||||
|
||||
- 이것은 `Refocus System` 다음으로 이어지는 것이 아니라
|
||||
- **Refocus System 구현의 후반부**로 바로 이어진다
|
||||
|
||||
### Phase 4. Complete / Break Separation Polish
|
||||
|
||||
목적:
|
||||
|
||||
- `goal complete`, `break`, `return`의 의미와 재질감 분리
|
||||
- “쉬는 중”과 “복귀 결정”이 섞이지 않게 정리
|
||||
|
||||
상태:
|
||||
|
||||
- 현재 진행 중
|
||||
- 1차 material/tone 분리 반영 완료
|
||||
- 남은 것은 copy, motion, 선택 위계 미세 조정
|
||||
|
||||
### Phase 5. Weekly Review Reframe
|
||||
|
||||
목적:
|
||||
|
||||
- total time보다 시작 성공률, 복귀율, 유지 패턴 중심으로 재설계
|
||||
|
||||
상태:
|
||||
|
||||
- 상세 기획 문서 작성 완료
|
||||
- 1차 snapshot 구현 완료
|
||||
- `/app -> /stats -> /app` entry flow 구현 완료
|
||||
- `/space` complete 이후 secondary teaser 구현 완료
|
||||
- pause recovery 집계 연결 완료
|
||||
- 남은 것은 away recovery event schema, ritual fit, Free / Pro gating
|
||||
|
||||
문서:
|
||||
|
||||
- `../screens/stats/current/14_weekly_review_reframe_spec.md`
|
||||
- `../flows/current/15_app_stats_entry_flow_spec.md`
|
||||
|
||||
### Phase 5.5. Session Routing / Paused Re-entry Alignment
|
||||
|
||||
문서:
|
||||
|
||||
- `../flows/current/18_paused_session_reentry_spec.md`
|
||||
|
||||
목적:
|
||||
|
||||
- `running focus -> /space`
|
||||
- `running break -> /space`
|
||||
- `paused focus -> /app`
|
||||
- explicit continue 이후 `/space` auto-resume
|
||||
- paused session 위의 new start를 takeover flow로만 허용
|
||||
|
||||
상태:
|
||||
|
||||
- 상세 기획 문서 작성 완료
|
||||
- Session Routing Contract 구현 완료
|
||||
- `/app` Paused Resume Gate 구현 완료
|
||||
- `/space` Auto-Resume Handoff 구현 완료
|
||||
- `Paused Session Takeover Flow` 구현 완료
|
||||
- 남은 것은 browser QA와 takeover 문구 polish
|
||||
|
||||
### Phase 6. Premium Ambience System
|
||||
|
||||
목적:
|
||||
|
||||
- Portal급 감각 품질 확보
|
||||
- 배경, 사운드, 전환, material의 art direction 통일
|
||||
|
||||
상태:
|
||||
|
||||
- 방향만 존재
|
||||
- review 전후 어느 시점에 들어갈지 조정 가능
|
||||
|
||||
---
|
||||
|
||||
## 5. “그 다음에 하려던 기획”은 무엇인가
|
||||
|
||||
Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가지였다.
|
||||
|
||||
1. `Refocus / Next Beat / Goal Complete`의 premium polish
|
||||
2. `Pause`와 `Break`의 감정/구조 분리
|
||||
|
||||
즉, 원래 예정된 기획은 사라진 것이 아니다.
|
||||
단지 순서가 아래처럼 정리된 것이다.
|
||||
|
||||
이전 예상 순서:
|
||||
|
||||
1. Refocus polish
|
||||
2. Break refinement
|
||||
3. Review
|
||||
|
||||
현재 확정 순서:
|
||||
|
||||
1. Refocus polish
|
||||
2. Away / Return Recovery
|
||||
3. Break refinement
|
||||
4. Review
|
||||
|
||||
정리하면:
|
||||
|
||||
- **Away / Return은 중간에 끼어든 임시 아이디어가 아니라**
|
||||
- `Break refinement`보다 먼저 처리해야 하는 선행 조건이다
|
||||
|
||||
---
|
||||
|
||||
## 6. 다음 세션에서의 실제 작업 순서
|
||||
|
||||
다음 작업은 아래 순서로 진행한다.
|
||||
|
||||
### Step 1. `/space` Refocus visual/behavior polish 마무리
|
||||
|
||||
포함:
|
||||
|
||||
- pause tray
|
||||
- refocus tray
|
||||
- next beat tray
|
||||
- goal complete tray
|
||||
|
||||
완료 기준:
|
||||
|
||||
- recovery flow가 checklist나 planner처럼 보이지 않는다
|
||||
- overlay는 한 번에 하나만 보인다
|
||||
|
||||
현재 상태:
|
||||
|
||||
- 대부분 구현 완료
|
||||
- 남은 것은 브라우저 기준 visual QA
|
||||
|
||||
### Step 2. Away / Return 구현
|
||||
|
||||
포함:
|
||||
|
||||
- `visibilitychange`
|
||||
- `pagehide`
|
||||
- sleep/wake delta 감지
|
||||
- `Return` tray 추가
|
||||
|
||||
완료 기준:
|
||||
|
||||
- focus ended while away 시 standard break로 바로 가지 않는다
|
||||
- return 상태에서 `지금부터 쉬기 / 이어가기 / 한 조각 다시 잡기`가 가능하다
|
||||
|
||||
현재 상태:
|
||||
|
||||
- 구현 완료
|
||||
- 남은 것은 브라우저 기준 state transition QA
|
||||
|
||||
### Step 3. Break 분리
|
||||
|
||||
포함:
|
||||
|
||||
- `Pause`, `Break`, `Return` 카피와 material 분리
|
||||
- break를 recovery tone이 아니라 release tone으로 재설계
|
||||
|
||||
현재 상태:
|
||||
|
||||
- 진행 중
|
||||
- 다음 핵심 작업
|
||||
|
||||
### Step 4. Review 상세 기획
|
||||
|
||||
포함:
|
||||
|
||||
- started
|
||||
- resumed
|
||||
- completed
|
||||
- recovery rate
|
||||
- ritual success correlation
|
||||
|
||||
현재 상태:
|
||||
|
||||
- 아직 시작 전
|
||||
|
||||
---
|
||||
|
||||
## 7. 현재 진행률 한눈에 보기
|
||||
|
||||
### 완료
|
||||
|
||||
- Product Principles
|
||||
- `/app` Entry Reframe
|
||||
- Refocus core implementation
|
||||
- Away / Return core implementation
|
||||
|
||||
### 진행 중
|
||||
|
||||
- `Pause / Break / Return` separation polish
|
||||
- material 1차 분리 반영 완료
|
||||
- copy / CTA hierarchy 2차 분리 반영 완료
|
||||
- motion polish 1차 반영 완료
|
||||
- 남은 것은 browser QA와 motion 미세 조정
|
||||
|
||||
### 다음 대기
|
||||
|
||||
- `Paused Session Re-entry` 구현
|
||||
- `/space` recovery browser QA
|
||||
- Weekly Review ritual fit highlight
|
||||
- Premium Ambience System
|
||||
|
||||
### 방금 완료
|
||||
|
||||
- `Weekly Review Entry Flow` Slice 1
|
||||
- `/app`에 low-emphasis weekly review entry를 추가
|
||||
- 충분한 최근 7일 데이터가 있을 때만 `/stats` primary entry를 노출
|
||||
- `Weekly Review Entry Flow` Slice 2
|
||||
- `/stats` 마지막 CTA가 `/app?review=weekly&carryHint=...` handoff로 연결
|
||||
- `/app`은 review-aware return hint를 먼저 보여주고, goal 입력은 그대로 사용자가 결정한다
|
||||
- `Weekly Review Entry Flow` Slice 3
|
||||
- Pro에서는 `/stats` carry-forward에 추천 ritual을 함께 보여준다
|
||||
- `/stats` 마지막 CTA와 `/app` return hint가 더 구체적인 next-session handoff로 바뀐다
|
||||
- `Weekly Review Entry Flow` Slice 4
|
||||
- `/space`에서 goal complete로 setup 상태로 돌아온 직후에만 secondary review entry가 보인다
|
||||
- full review 강제 이동 없이 작은 후행 경로로 `/stats`를 연다
|
||||
- 다음 구현은 weekly review의 ritual fit highlight 또는 deeper recovery schema다
|
||||
- `Paused Session Re-entry` spec
|
||||
- running / paused / break의 route policy를 한 문서에서 고정했다
|
||||
- `/app`은 paused session의 resume gate가 되고, explicit continue 이후 `/space`에서는 자동 resume해야 한다
|
||||
- paused session 위의 새 시작은 takeover flow로만 허용한다
|
||||
- `Paused Session Re-entry` Slice 1
|
||||
- `/app`은 running session을 감지하면 hero 대신 즉시 `/space`로 보낸다
|
||||
- `/space`는 paused session 상태에서 explicit handoff intent 없이 직접 열리면 `/app`으로 되돌린다
|
||||
- 다음 구현은 paused resume gate와 auto-resume handoff다
|
||||
- `Paused Session Re-entry` Slice 2-3
|
||||
- `/app` paused 상태에 `이어서 몰입하기`, `한 조각 다시 잡기`, quiet `주간 review 보기`를 올렸다
|
||||
- explicit continue 이후 `/space`는 자동 resume된다
|
||||
- refocus handoff는 `/space?resume=refocus`로 들어가며, 진입 직후 refocus tray를 연다
|
||||
- 다음 구현은 takeover flow다
|
||||
|
||||
---
|
||||
|
||||
## 8. 의사결정 규칙
|
||||
|
||||
새 기능이나 새 기획이 들어올 때는 아래 규칙으로 판단한다.
|
||||
|
||||
### 바로 진행해도 되는 경우
|
||||
|
||||
- 기존 코어 루프의 빈칸을 메우는 경우
|
||||
- `start / immerse / refocus / return / complete` 중 하나를 선명하게 하는 경우
|
||||
|
||||
### 뒤로 미뤄야 하는 경우
|
||||
|
||||
- list/planner/task manager 느낌을 강하게 만드는 경우
|
||||
- social, buddy, coworking 쪽으로 제품 무게를 옮기는 경우
|
||||
- 화면에 오브젝트와 설정을 더 많이 추가하는 경우
|
||||
|
||||
---
|
||||
|
||||
## 9. 한 줄 결론
|
||||
|
||||
현재 끼어든 `Away / Return` 기획은 옆길이 아니다.
|
||||
|
||||
> 이것은 `Refocus System`을 실제 사용자 행동에 맞게 완성하기 위해 반드시 먼저 처리해야 하는 다음 단계다.
|
||||
|
||||
즉, 다음에 원래 하려던 기획은 사라지지 않았고,
|
||||
순서는 아래처럼 재정렬되었다.
|
||||
|
||||
1. Refocus polish
|
||||
2. Away / Return Recovery
|
||||
3. Break refinement
|
||||
4. Weekly Review
|
||||
|
||||
현재 위치:
|
||||
|
||||
> 코어 루프 기준으로는 `4. Weekly Review`의 entry flow 구현까지 마친 상태이고,
|
||||
> 운영 기준으로는 이제 `Product Alignment Audit`을 통해 core loop 전반의 기획-구현 정합성을 전수 점검하는 단계다.
|
||||
602
docs/product/16_product_alignment_audit_plan.md
Normal file
602
docs/product/16_product_alignment_audit_plan.md
Normal file
@@ -0,0 +1,602 @@
|
||||
# 16. Product Alignment Audit Plan
|
||||
|
||||
Last Updated: 2026-03-15
|
||||
|
||||
이 문서는 VibeRoom 전체에서 **기획과 구현이 어긋난 부분을 체계적으로 찾아내고, 실제 수정 작업까지 연결하는 실행 계획**이다.
|
||||
|
||||
목표는 단순하다.
|
||||
|
||||
> 우연히 발견한 불일치를 그때그때 고치는 방식에서 벗어나,
|
||||
> 어떤 세션과 어떤 에이전트가 들어와도 같은 기준으로 앱 전체를 점검하고 닫을 수 있는 운영 체계를 만든다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 왜 이 문서가 필요한가
|
||||
|
||||
최근 점검에서 이미 아래 문제가 반복적으로 드러났다.
|
||||
|
||||
- 카피는 `break`를 약속하는데 실제 동작은 `pause`인 경우
|
||||
- spec은 `/app`이 review의 primary entry라고 적혀 있는데 구현에서는 resume 상태에서 review entry가 사라지는 경우
|
||||
- `goal click`, `수정`, `expand/collapse`의 역할 분리가 코드와 문서에서 다르게 남는 경우
|
||||
- `/stats -> /app` handoff가 실제 행동을 바꾸지 않는데 문구는 개인화된 추천을 약속하는 경우
|
||||
|
||||
즉 현재 문제는 “버그가 많다”가 아니라,
|
||||
**기획 / 카피 / 상태 모델 / 실제 코드 / 문서가 동시에 drift할 수 있는 구조**라는 점이다.
|
||||
|
||||
이 audit plan의 목적은 아래 4개다.
|
||||
|
||||
1. source of truth를 고정한다
|
||||
2. route / state / copy / BM claim을 같은 표로 점검한다
|
||||
3. 발견된 불일치를 severity와 수정 단위로 즉시 분류한다
|
||||
4. 수정 후 문서와 QA까지 같은 라운드에서 닫는다
|
||||
|
||||
---
|
||||
|
||||
## 2. Audit의 정의
|
||||
|
||||
이 문서에서 말하는 `alignment audit`는 아래 5개를 동시에 보는 점검이다.
|
||||
|
||||
### 1. Product Alignment
|
||||
|
||||
- 제품 원칙과 실제 기능이 맞는가
|
||||
- single-goal, single-microStep, commitment-first 원칙이 지켜지는가
|
||||
|
||||
### 2. Flow Alignment
|
||||
|
||||
- 사용자가 실제로 밟는 경로가 기획된 유저 플로우와 맞는가
|
||||
- entry / recovery / complete / review의 연결이 끊기지 않는가
|
||||
|
||||
### 3. Copy Alignment
|
||||
|
||||
- 화면 문구가 실제 동작을 과장하거나 왜곡하지 않는가
|
||||
- 초심자도 용어 없이 이해 가능한가
|
||||
|
||||
### 4. State Alignment
|
||||
|
||||
- pause / break / return / complete / resume 같은 상태 의미가 코드와 카피에서 일치하는가
|
||||
|
||||
### 5. Business Alignment
|
||||
|
||||
- Free / Pro 가치 제안이 실제 제품 행동으로 느껴지는가
|
||||
- “된다고 쓰여 있지만 실제로는 안 되는” BM성 약속이 없는가
|
||||
|
||||
---
|
||||
|
||||
## 3. Source of Truth 우선순위
|
||||
|
||||
Audit 중 충돌이 생기면 아래 순서로 판단한다.
|
||||
|
||||
1. `product_principles.md`
|
||||
2. `/Users/ijeongmin/Desktop/corpi/viberoom/current_context.md`
|
||||
3. route/flow 관련 상세 spec
|
||||
- `../screens/app/current/19_app_atmosphere_entry_spec.md`
|
||||
- `../flows/current/10_refocus_system_spec.md`
|
||||
- `../flows/current/11_away_return_recovery_spec.md`
|
||||
- `../screens/space/current/13_space_intent_card_collapsed_expanded_spec.md`
|
||||
- `../screens/stats/current/14_weekly_review_reframe_spec.md`
|
||||
- `../flows/current/15_app_stats_entry_flow_spec.md`
|
||||
4. `90_current_state.md`
|
||||
5. 실제 코드
|
||||
|
||||
중요:
|
||||
|
||||
- 코드가 source of truth가 아니다
|
||||
- 하지만 문서가 낡았고 코드가 더 최근 의도를 반영한다면, 그 차이를 `finding`으로 기록한 뒤 문서를 업데이트한다
|
||||
|
||||
---
|
||||
|
||||
## 4. Audit 범위
|
||||
|
||||
### 4.1 Route 범위
|
||||
|
||||
반드시 본다.
|
||||
|
||||
- `/`
|
||||
- `/login`
|
||||
- `/app`
|
||||
- `/space`
|
||||
- `/stats`
|
||||
- `/settings`
|
||||
- `/admin`
|
||||
|
||||
### 4.2 Core Product Flow 범위
|
||||
|
||||
반드시 본다.
|
||||
|
||||
- Landing -> Auth -> `/app`
|
||||
- `/app` new start
|
||||
- `/app` resume
|
||||
- `/space` focus
|
||||
- `/space` pause
|
||||
- `/space` return
|
||||
- `/space` next beat
|
||||
- `/space` goal complete
|
||||
- `/space` complete -> setup
|
||||
- `/app -> /stats -> /app`
|
||||
- paywall / Pro handoff
|
||||
|
||||
### 4.3 Cross-cutting 범위
|
||||
|
||||
반드시 본다.
|
||||
|
||||
- i18n copy
|
||||
- query param handoff
|
||||
- API contract / optimistic UI
|
||||
- plan tier gating
|
||||
- toast / reminder / empty state
|
||||
- docs drift
|
||||
|
||||
---
|
||||
|
||||
## 5. Audit 원칙
|
||||
|
||||
### 1. 화면이 아니라 “행동 단위”로 점검한다
|
||||
|
||||
예:
|
||||
|
||||
- `Goal Complete` 화면 자체를 보는 게 아니라
|
||||
- `사용자가 목표를 끝냈다고 느끼는 순간 -> 선택 -> 결과 상태` 전체를 본다
|
||||
|
||||
### 2. copy와 behavior를 반드시 함께 본다
|
||||
|
||||
카피만 맞고 동작이 다르면 실패다.
|
||||
동작은 맞는데 카피가 과장되면 그것도 실패다.
|
||||
|
||||
### 3. Free / Pro 약속은 더 엄격하게 본다
|
||||
|
||||
BM에 연결된 카피는 일반 카피보다 더 엄격하게 검증한다.
|
||||
|
||||
예:
|
||||
|
||||
- “추천 ritual로 돌아가기”
|
||||
- “방금 끝낸 흐름까지 담아 review”
|
||||
- “주간 review가 다음 세션을 돕는다”
|
||||
|
||||
### 4. 한 라운드에서 `finding -> fix -> docs -> commit`까지 닫는다
|
||||
|
||||
발견만 하고 쌓아두지 않는다.
|
||||
|
||||
### 5. 브라우저 QA 없이 닫지 않는다
|
||||
|
||||
`tsc`, `eslint` 통과는 필수지만 충분조건이 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. Finding 분류 기준
|
||||
|
||||
### P1
|
||||
|
||||
제품 의미가 뒤틀리는 불일치
|
||||
|
||||
예:
|
||||
|
||||
- break로 쓰였는데 실제로 break가 아님
|
||||
- primary entry가 없어짐
|
||||
- Pro value를 약속하지만 실제로 작동하지 않음
|
||||
|
||||
### P2
|
||||
|
||||
사용자 플로우가 헷갈리거나 약속이 약해지는 불일치
|
||||
|
||||
예:
|
||||
|
||||
- 문구는 맞지만 진입점이 숨겨짐
|
||||
- 2단계 flow인데 1단계처럼 보임
|
||||
- recovery 상태가 시각적으로 구분되지 않음
|
||||
|
||||
### P3
|
||||
|
||||
문서 / 카피 / 접근성 / 잔여 코드 드리프트
|
||||
|
||||
예:
|
||||
|
||||
- 오래된 spec 표현
|
||||
- dead i18n key
|
||||
- aria-label과 가시 UI가 어긋남
|
||||
|
||||
---
|
||||
|
||||
## 7. 실제 Audit 방법
|
||||
|
||||
Audit은 아래 6단계로 진행한다.
|
||||
|
||||
### Phase 0. Baseline Freeze
|
||||
|
||||
산출물:
|
||||
|
||||
- 현재 기준 문서 목록
|
||||
- 현재 route / widget ownership map
|
||||
- 현재 work order
|
||||
|
||||
실행:
|
||||
|
||||
- `current_context.md`
|
||||
- `90_current_state.md`
|
||||
- `./12_core_loop_execution_roadmap.md`
|
||||
- route map / session brief 확인
|
||||
|
||||
목적:
|
||||
|
||||
- 무엇이 기준인지 먼저 잠근다
|
||||
|
||||
---
|
||||
|
||||
### Phase 1. Route & Flow Inventory
|
||||
|
||||
각 route마다 아래를 표로 만든다.
|
||||
|
||||
- route
|
||||
- intended role
|
||||
- main CTA
|
||||
- secondary CTA
|
||||
- entry source
|
||||
- exit target
|
||||
- hidden states
|
||||
- Pro-specific behavior
|
||||
|
||||
산출물:
|
||||
|
||||
- `route-flow matrix`
|
||||
|
||||
점검 질문:
|
||||
|
||||
- 사용자는 이 route에 왜 오는가
|
||||
- 여기서 무엇을 해야 하는가
|
||||
- 다음 화면은 어디인가
|
||||
- 실제 코드가 이 흐름을 유지하는가
|
||||
|
||||
---
|
||||
|
||||
### Phase 2. State Contract Audit
|
||||
|
||||
`/space`와 `/app`의 상태 전이를 표로 만든다.
|
||||
|
||||
필수 상태:
|
||||
|
||||
- setup
|
||||
- focus
|
||||
- paused
|
||||
- return
|
||||
- refocus
|
||||
- next-beat
|
||||
- complete-choice
|
||||
- complete-next
|
||||
- break
|
||||
- review-entry
|
||||
|
||||
각 상태마다 본다.
|
||||
|
||||
- state 이름
|
||||
- 사용자가 이해하는 의미
|
||||
- 실제 trigger
|
||||
- visible UI
|
||||
- exit actions
|
||||
- side effects
|
||||
|
||||
산출물:
|
||||
|
||||
- `state contract matrix`
|
||||
|
||||
핵심 목적:
|
||||
|
||||
- 이름만 있고 실제 의미가 다른 상태를 찾는다
|
||||
|
||||
---
|
||||
|
||||
### Phase 3. Copy / Behavior Audit
|
||||
|
||||
각 핵심 CTA에 대해 아래를 점검한다.
|
||||
|
||||
- label
|
||||
- user promise
|
||||
- actual effect
|
||||
- mismatch 여부
|
||||
|
||||
예:
|
||||
|
||||
- `잠시 비우기`
|
||||
- `쉬기 이어가기`
|
||||
- `다음 목표로 바로 시작`
|
||||
- `주간 review 보기`
|
||||
- `추천 ritual과 함께 /app 돌아가기`
|
||||
|
||||
산출물:
|
||||
|
||||
- `copy-behavior mismatch list`
|
||||
|
||||
---
|
||||
|
||||
### Phase 4. Business Model Audit
|
||||
|
||||
Free / Pro를 아래 기준으로 본다.
|
||||
|
||||
- user sees
|
||||
- user clicks
|
||||
- actual unlocked behavior
|
||||
- value perception
|
||||
- overclaim risk
|
||||
|
||||
반드시 보는 곳:
|
||||
|
||||
- paywall copy
|
||||
- `/stats` Pro carry-forward
|
||||
- `/app` teaser / return hint
|
||||
- review insight / ritual recommendation
|
||||
|
||||
산출물:
|
||||
|
||||
- `BM promise audit`
|
||||
|
||||
---
|
||||
|
||||
### Phase 5. Browser Journey Audit
|
||||
|
||||
이 단계는 실제 브라우저에서 한다.
|
||||
|
||||
필수 시나리오:
|
||||
|
||||
1. 로그인 후 `/app` 첫 진입
|
||||
2. `/app`에서 새 goal 시작
|
||||
3. `/app` resume 상태
|
||||
4. `/space` pause -> refocus -> resume
|
||||
5. `/space` away -> return(focus)
|
||||
6. `/space` focus ended while away -> return(break)
|
||||
7. `/space` microStep done -> next beat
|
||||
8. `/space` goal complete choice
|
||||
9. `/space` goal complete next
|
||||
10. `/space` complete -> setup -> review teaser
|
||||
11. `/app` weekly review entry -> `/stats` -> `/app`
|
||||
12. Pro 상태 handoff
|
||||
|
||||
각 시나리오마다 본다.
|
||||
|
||||
- first meaningful paint
|
||||
- primary CTA visibility
|
||||
- copy comprehension
|
||||
- next action clarity
|
||||
- state transition correctness
|
||||
- dismiss behavior
|
||||
- mobile / desktop 차이
|
||||
|
||||
산출물:
|
||||
|
||||
- `browser QA findings`
|
||||
|
||||
---
|
||||
|
||||
### Phase 6. Fix Closure
|
||||
|
||||
각 finding은 아래 단위로 닫는다.
|
||||
|
||||
- finding 요약
|
||||
- severity
|
||||
- affected files
|
||||
- fix approach
|
||||
- docs to update
|
||||
- validation commands
|
||||
- manual QA notes
|
||||
- commit hash
|
||||
|
||||
이 단계까지 끝나야 “수정 완료”다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 실제 실행 순서
|
||||
|
||||
앱 전체 audit은 아래 순서로 진행한다.
|
||||
|
||||
### Slice A. Core Loop Alignment
|
||||
|
||||
범위:
|
||||
|
||||
- `/app`
|
||||
- `/space`
|
||||
- `/stats`
|
||||
|
||||
우선 점검:
|
||||
|
||||
- entry
|
||||
- recovery
|
||||
- completion
|
||||
- review handoff
|
||||
|
||||
이유:
|
||||
|
||||
- VibeRoom의 돈 되는 핵심 가치가 여기 있다
|
||||
|
||||
### Slice B. Monetization Alignment
|
||||
|
||||
범위:
|
||||
|
||||
- paywall
|
||||
- plan tier
|
||||
- Pro copy
|
||||
- Pro actual behavior
|
||||
|
||||
이유:
|
||||
|
||||
- BM 과장은 제품 신뢰를 가장 빠르게 해친다
|
||||
|
||||
### Slice C. Shell & Supporting Routes
|
||||
|
||||
범위:
|
||||
|
||||
- `/`
|
||||
- `/login`
|
||||
- `/settings`
|
||||
- `/admin`
|
||||
|
||||
이유:
|
||||
|
||||
- 핵심 루프보다 우선순위는 낮지만, 전체 완성도와 신뢰에 영향이 크다
|
||||
|
||||
### Slice D. Docs & Regression Guard
|
||||
|
||||
범위:
|
||||
|
||||
- `current_context.md`
|
||||
- `90_current_state.md`
|
||||
- detailed specs
|
||||
- lint / dead copy / route map
|
||||
|
||||
이유:
|
||||
|
||||
- 다음 세션에서 다시 어긋나지 않게 만드는 단계다
|
||||
|
||||
---
|
||||
|
||||
## 9. 추천 산출물
|
||||
|
||||
이 audit을 실제로 굴릴 때는 아래 3개 파일이 필요하다.
|
||||
|
||||
### 1. 기준 문서
|
||||
|
||||
- 이 문서 `./16_product_alignment_audit_plan.md`
|
||||
|
||||
### 2. 실제 finding ledger
|
||||
|
||||
추천 파일:
|
||||
|
||||
- `./17_product_alignment_findings.md`
|
||||
|
||||
형식:
|
||||
|
||||
- ID
|
||||
- severity
|
||||
- route / flow
|
||||
- product promise
|
||||
- actual behavior
|
||||
- affected files
|
||||
- fix status
|
||||
|
||||
### 3. execution checklist
|
||||
|
||||
추천 위치:
|
||||
|
||||
- `work.md`
|
||||
|
||||
형식:
|
||||
|
||||
- 이번 slice에서 확인할 시나리오
|
||||
- 수정 범위
|
||||
- 제외 범위
|
||||
- 완료 조건
|
||||
|
||||
---
|
||||
|
||||
## 10. 이 audit을 어떻게 실행할 것인가
|
||||
|
||||
### 라운드 단위 규칙
|
||||
|
||||
한 라운드는 아래 순서로만 진행한다.
|
||||
|
||||
1. 이번 slice 범위 결정
|
||||
2. static audit
|
||||
3. browser audit
|
||||
4. findings 정리
|
||||
5. fix
|
||||
6. docs update
|
||||
7. validation
|
||||
8. commit
|
||||
|
||||
### 각 라운드 출력 형식
|
||||
|
||||
항상 아래 형식으로 닫는다.
|
||||
|
||||
- 추가
|
||||
- 수정
|
||||
- 삭제
|
||||
- 검증
|
||||
- 커밋
|
||||
|
||||
### 커밋 규칙
|
||||
|
||||
- 한 주제 = 한 커밋
|
||||
- 기획 불일치 정리는 `fix(flow): ...`
|
||||
- 문서 중심 라운드는 `docs(product): ...`
|
||||
|
||||
---
|
||||
|
||||
## 11. 바로 다음 실행 계획
|
||||
|
||||
### 실행 1. `./17_product_alignment_findings.md` 생성
|
||||
|
||||
범위:
|
||||
|
||||
- 현재까지 이미 드러난 core loop mismatch를 먼저 기록
|
||||
|
||||
포함:
|
||||
|
||||
- break semantics
|
||||
- review entry visibility
|
||||
- review teaser overclaim
|
||||
- Pro handoff realism
|
||||
- docs drift
|
||||
|
||||
### 실행 2. Slice A static audit
|
||||
|
||||
대상:
|
||||
|
||||
- `/app`
|
||||
- `/space`
|
||||
- `/stats`
|
||||
|
||||
목표:
|
||||
|
||||
- route-flow matrix
|
||||
- state contract matrix
|
||||
- copy-behavior mismatch list
|
||||
|
||||
### 실행 3. Slice A browser audit
|
||||
|
||||
대상 시나리오:
|
||||
|
||||
1. `/app` first entry
|
||||
2. `/app` resume
|
||||
3. `/app -> /stats -> /app`
|
||||
4. `/space` pause / return / complete
|
||||
5. `/space complete -> setup -> review teaser`
|
||||
|
||||
### 실행 4. findings fix round
|
||||
|
||||
원칙:
|
||||
|
||||
- P1 먼저
|
||||
- 그 다음 P2
|
||||
- P3는 같은 파일을 만질 때 함께 정리
|
||||
|
||||
---
|
||||
|
||||
## 12. 완료 기준
|
||||
|
||||
Alignment audit이 제대로 굴러간 상태는 아래와 같다.
|
||||
|
||||
- 중요한 CTA가 실제 동작을 과장하지 않는다
|
||||
- primary / secondary entry가 문서와 코드에서 동일하다
|
||||
- pause / break / return / complete의 의미가 사용자 기준으로도 분리된다
|
||||
- Free / Pro 가치 제안이 실제 행동으로 느껴진다
|
||||
- 다음 세션/에이전트가 문서를 읽고도 현재 제품 상태를 오해하지 않는다
|
||||
|
||||
---
|
||||
|
||||
## 13. 절대 하지 말아야 할 것
|
||||
|
||||
- 브라우저 QA 없이 문서만 보고 “정상”이라고 판단
|
||||
- 카피만 바꾸고 실제 상태 모델은 그대로 두기
|
||||
- BM 문구를 구현보다 앞서 나가게 두기
|
||||
- `current_context.md`를 나중에 한꺼번에 갱신하기
|
||||
- 한 라운드에 너무 많은 route를 동시에 수정하기
|
||||
|
||||
---
|
||||
|
||||
## 14. 최종 판단
|
||||
|
||||
VibeRoom이 premium product가 되려면,
|
||||
UI refinement만으로는 부족하다.
|
||||
|
||||
정말 중요한 것은:
|
||||
|
||||
> 사용자가 읽는 문구, 누르는 행동, 실제로 일어나는 상태 변화,
|
||||
> 그리고 그걸 설명하는 문서가 모두 같은 제품을 말하고 있어야 한다.
|
||||
|
||||
이 audit plan은 그 정합성을 제품 운영의 기본 프로세스로 만들기 위한 문서다.
|
||||
132
docs/product/17_product_alignment_findings.md
Normal file
132
docs/product/17_product_alignment_findings.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 17. Product Alignment Findings
|
||||
|
||||
Last Updated: 2026-03-15
|
||||
|
||||
이 문서는 `./16_product_alignment_audit_plan.md`를 실제로 운영하기 위한 **findings ledger**다.
|
||||
|
||||
목표는 단순하다.
|
||||
|
||||
> VibeRoom의 core loop에서 발견된 기획-구현 불일치를
|
||||
> severity, 실제 영향, 수정 상태 기준으로 누적 관리한다.
|
||||
|
||||
이 파일은 단순 회고 메모가 아니다.
|
||||
|
||||
- 어떤 문제가 있었는지
|
||||
- 무엇을 약속하고 있었는지
|
||||
- 실제로는 어떻게 동작했는지
|
||||
- 어느 파일을 건드렸는지
|
||||
- 지금은 열린 문제인지, 수정됐지만 브라우저 확인이 남았는지
|
||||
|
||||
를 한 번에 보게 만드는 운영 문서다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 상태 정의
|
||||
|
||||
### `open`
|
||||
|
||||
- 아직 수정 전
|
||||
- 다음 작업 라운드에서 다뤄야 함
|
||||
|
||||
### `fixed-awaiting-browser`
|
||||
|
||||
- 코드와 문서는 정리됨
|
||||
- 브라우저 기준 최종 QA가 남아 있음
|
||||
|
||||
### `closed`
|
||||
|
||||
- 코드 / 문서 / 브라우저 확인까지 끝남
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 범위
|
||||
|
||||
이번 ledger v1은 아래 core loop 범위만 다룬다.
|
||||
|
||||
- `/app`
|
||||
- `/space`
|
||||
- `/stats`
|
||||
- review handoff
|
||||
- break / pause / return semantics
|
||||
- Pro personalized handoff
|
||||
|
||||
`/settings`, `/admin`, landing shell의 정합성 감사는 다음 slice로 미룬다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings
|
||||
|
||||
| ID | Severity | Area | Product Promise | Actual Behavior | Affected Files | Status | Next Action |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| ALN-001 | P1 | `/space` Goal Complete / Break semantics | `잠깐 쉬기`는 블록을 닫고 break로 넘어가는 것처럼 읽힘 | 실제로는 overlay만 닫고 reminder만 예약되어 break 의미가 깨졌음 | `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx`, `src/shared/i18n/messages/space.ts` | fixed-awaiting-browser | `잠시 비우기 -> pause + reminder`가 실제 체감상도 맞는지 브라우저 확인 |
|
||||
| ALN-002 | P1 | `/app` Weekly Review primary entry | `/app`이 Weekly Review의 primary entry라고 정의됨 | current session이 있으면 review entry가 완전히 사라져 primary entry가 끊겼음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `docs/flows/current/15_app_stats_entry_flow_spec.md` | fixed-awaiting-browser | resume 상태에서 review entry 발견성과 우선순위 확인 |
|
||||
| ALN-003 | P2 | `/space` secondary review teaser | `방금 끝낸 흐름까지 review에 담아둘까요?`처럼 read-after-write를 약속했음 | 실제로는 generic `/stats`만 열고, 방금 끝낸 흐름을 별도 handoff하지 않았음 | `src/shared/i18n/messages/space.ts`, `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx` | fixed-awaiting-browser | setup drawer teaser가 과장 없이 자연스럽게 읽히는지 확인 |
|
||||
| ALN-004 | P1 | Pro personalized handoff | `/stats`에서 추천 atmosphere/duration으로 돌아간다고 말했음 | 실제로는 `/app` 문구만 바뀌고 start behavior는 기본값 그대로였음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `src/shared/i18n/messages/app.ts` | fixed-awaiting-browser | `entryAtmosphereId + entryDurationMinutes`가 실제 start 값으로 적용되는지 검증 |
|
||||
| ALN-005 | P2 | `/space` intent card interaction | rail, edit, expand/collapse의 역할이 분리돼야 함 | goal 클릭과 edit 진입이 섞여 예측 가능성이 낮았음 | `src/widgets/space-focus-hud/ui/IntentCapsule.tsx`, `docs/screens/space/current/13_space_intent_card_collapsed_expanded_spec.md` | fixed-awaiting-browser | desktop/mobile에서 expand와 edit 구분이 분명한지 확인 |
|
||||
| ALN-006 | P2 | `/space` Goal Complete 2단계 인지 | 1단계 choice와 2단계 next 입력이 명확히 구분돼야 함 | 사용자 입장에서는 `돌아가기 / 다음 목표로 바로 시작` 화면이 top-level 분기처럼 읽히기 쉬움 | `src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx`, `src/shared/i18n/messages/space.ts` | open | choice view와 next view의 제목, 구조, motion, context label을 더 분리하는 기획 필요 |
|
||||
| ALN-007 | P2 | Weekly Review discoverability | review는 `/app`의 primary ritual이어야 함 | 데이터 gate와 currentSession 조건에 따라 사용자에게 “아예 없는 기능”처럼 느껴질 수 있음 | `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `docs/flows/current/15_app_stats_entry_flow_spec.md` | open | low-data 상태와 resume 상태를 포함한 discoverability 정책 재정의 |
|
||||
| ALN-008 | P1 | `잠시 비우기`와 `Break`의 제품 의미 | break는 reward/reset, pause는 recovery로 분리돼야 함 | 현재는 카피와 트레이는 개선됐지만, 제품 차원의 최종 정의와 시각 분리까지 완전히 닫히진 않았음 | `docs/flows/current/10_refocus_system_spec.md`, `docs/flows/current/11_away_return_recovery_spec.md`, `src/widgets/space-focus-hud/ui/GoalCompleteSheet.tsx`, `src/widgets/space-focus-hud/ui/ReturnPrompt.tsx` | open | `잠시 비우기`, active break, return(break)를 하나의 최종 state model로 재정의 |
|
||||
| ALN-009 | P3 | Spec / current-state drift | 다음 세션 문서가 실제 구현과 맞아야 함 | intent card, goal complete, review entry 관련 오래된 표현이 여러 spec에 남아 있었음 | `docs/flows/current/10_refocus_system_spec.md`, `docs/screens/space/current/13_space_intent_card_collapsed_expanded_spec.md`, `docs/90_current_state.md`, `docs/session_brief.md`, `../../current_context.md` | fixed-awaiting-browser | 이후 라운드부터는 fix와 문서 갱신을 같은 커밋에서 닫는지 점검 |
|
||||
| ALN-010 | P1 | paused session 재진입 정책 | running은 바로 `/space`, paused는 `/app` resume gate, explicit continue 이후에는 자동 resume이어야 함 | Session Routing Contract, paused resume gate, auto-resume handoff, takeover flow까지 구현됐다. 남은 것은 browser QA와 takeover wording polish이다 | `docs/flows/current/18_paused_session_reentry_spec.md`, `src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx`, `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`, `src/features/focus-session/model/useFocusSessionEngine.ts` | fixed-awaiting-browser | paused resume / refocus / takeover 3경로를 브라우저에서 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 요약 판단
|
||||
|
||||
### 현재 이미 수습된 축
|
||||
|
||||
- break/pause 카피와 실제 동작의 큰 충돌
|
||||
- `/app` resume 상태의 review entry 공백
|
||||
- `/space` review teaser 과장 카피
|
||||
- Pro ritual handoff의 실동작 부재
|
||||
- intent card interaction ambiguity 일부
|
||||
|
||||
### 아직 열린 축
|
||||
|
||||
- `Goal Complete` 2단계 구조의 인지 부담
|
||||
- low-data / resume 상태에서 review discoverability
|
||||
- `잠시 비우기`와 진짜 break의 최종 state model
|
||||
- paused session의 route / resume / takeover contract
|
||||
|
||||
---
|
||||
|
||||
## 5. 다음 실행 우선순위
|
||||
|
||||
### 1. Static audit continuation
|
||||
|
||||
다음 문서가 필요하다.
|
||||
|
||||
- route-flow matrix
|
||||
- state contract matrix
|
||||
|
||||
즉, 다음 라운드는 `설명 가능한 표`를 만드는 단계다.
|
||||
|
||||
### 2. Browser audit
|
||||
|
||||
반드시 아래 순서로 확인한다.
|
||||
|
||||
1. `/app` no-session entry
|
||||
2. `/app` resume + review entry
|
||||
3. `/app -> /stats -> /app`
|
||||
4. `/space` goal complete choice
|
||||
5. `/space` goal complete next
|
||||
6. `/space` 잠시 비우기
|
||||
7. `/space` return(focus)
|
||||
8. `/space` return(break)
|
||||
9. `/space` complete -> setup -> review teaser
|
||||
|
||||
### 3. 다음 수정 라운드의 우선순위
|
||||
|
||||
1. ALN-010
|
||||
2. ALN-008
|
||||
3. ALN-006
|
||||
4. ALN-007
|
||||
|
||||
---
|
||||
|
||||
## 6. 운영 규칙
|
||||
|
||||
- 새로운 mismatch를 찾으면 이 문서에 먼저 기록한다
|
||||
- severity 없이 추가하지 않는다
|
||||
- `fixed-awaiting-browser`는 브라우저 확인 전까지 닫지 않는다
|
||||
- 실제 fix가 끝나면 관련 commit hash를 이 문서에 추가한다
|
||||
22
docs/screens/app/README.md
Normal file
22
docs/screens/app/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# `/app` Docs
|
||||
|
||||
`/app` 화면 관련 문서를 모아둔 폴더다.
|
||||
|
||||
## current
|
||||
|
||||
지금 `/app` 작업에 직접 영향을 주는 source-of-truth 문서다.
|
||||
|
||||
- [19_app_atmosphere_entry_spec.md](./current/19_app_atmosphere_entry_spec.md)
|
||||
|
||||
## archive
|
||||
|
||||
이전 `/app` 방향이나 구현 완료 후 더 이상 기준 문서가 아닌 자료다.
|
||||
|
||||
- [08_app_reframe_strategy.md](./archive/08_app_reframe_strategy.md)
|
||||
|
||||
## 같이 볼 문서
|
||||
|
||||
`/app`은 화면 자체 문서 외에도 cross-screen flow 영향을 많이 받는다.
|
||||
|
||||
- [../../flows/current/15_app_stats_entry_flow_spec.md](../../flows/current/15_app_stats_entry_flow_spec.md)
|
||||
- [../../flows/current/18_paused_session_reentry_spec.md](../../flows/current/18_paused_session_reentry_spec.md)
|
||||
61
docs/screens/app/archive/08_app_reframe_strategy.md
Normal file
61
docs/screens/app/archive/08_app_reframe_strategy.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# VibeRoom `/app` 프로덕트 고도화 및 수익화 기획안
|
||||
|
||||
## 1. 문제 정의: 왜 `/app`의 존재 의의가 애매해졌는가?
|
||||
현재 `/app` 화면은 단순히 '목표를 하나 입력하고 `/space`로 넘어가는 정거장' 역할만 수행하고 있습니다.
|
||||
사용자 입장에서는 "어차피 `/space`에서 타이머 돌리고 일할 건데, 굳이 `/app`이라는 별도 화면에서 목표 하나만 달랑 쳐야 하나?"라는 기능적 회의감이 들 수밖에 없습니다. 즉, **경험적 가치(UX Value) 없이 Depth만 하나 늘어난 상태**입니다.
|
||||
|
||||
## 2. 타겟 인사이트: ADHD & 프리랜서의 심리
|
||||
이 서비스의 핵심 타겟은 **ADHD 성향을 가진 사람들과 혼자 일하는 프리랜서**입니다. 이들의 가장 큰 페인포인트는 다음과 같습니다.
|
||||
1. **시작의 두려움 (High Activation Energy):** '무엇을 해야 할지'는 알지만, 첫 단추를 꿰는 것을 극도로 미룹니다.
|
||||
2. **작업 기억 용량 초과 (RAM Overload):** 머릿속에 해야 할 일과 잡념이 뒤엉켜 있어 하나에 집중하지 못합니다.
|
||||
3. **도파민 갈구 (Dopamine Seeking):** 즉각적인 피드백이나 보상이 없으면 쉽게 지루해하고 이탈합니다.
|
||||
|
||||
## 3. 핵심 전략: `/app`을 '단순 입력창'에서 **'몰입을 위한 의식(Ritual)의 공간'**으로 재정의
|
||||
최고의 서비스는 사용자에게 '행동'을 요구하기 전에 '감정'을 만져줍니다. `/app`은 투두리스트(To-do)를 적는 곳이 아닙니다. **복잡한 현실에서 벗어나, 고도의 집중 상태(`/space`)로 들어가기 전 마음을 다잡고 준비 운동을 하는 '감압실(Decompression Chamber)'**이 되어야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. `/app` 공간의 4가지 핵심 기능 기획
|
||||
|
||||
### A. Brain Dump (뇌 비우기) & The One Thing
|
||||
ADHD 유저에게 "지금 할 목표 하나만 적으세요"라고 하면 오히려 압박감을 느낍니다.
|
||||
- **기능:** 머릿속에 맴도는 모든 잡념과 해야 할 일들을 일단 마구잡이로 쏟아내게 합니다(Brain Dump).
|
||||
- **UX:** 쏟아낸 여러 항목 중 **"지금 당장 딱 하나만 쥐고 `/space`로 들어갑시다. 나머지는 저희가 안전하게 보관해 둘게요"**라며 단 하나의 목표(The One Thing)를 선택하게 유도합니다.
|
||||
- **효과:** 머릿속 RAM을 비워줌으로써 인지적 과부하를 해결하고 안도감을 줍니다.
|
||||
|
||||
### B. Micro-Stepping (초소형 첫 단계 쪼개기)
|
||||
"제안서 작성하기"라는 목표는 너무 거대해서 시작을 미루게 만듭니다.
|
||||
- **기능:** 유저가 큰 목표를 선택했을 때, "이 목표를 위해 지금 당장 할 수 있는 5분짜리 행동은 무엇인가요?"라고 묻습니다.
|
||||
- **UX:** "제안서 폴더 열기", "노션 새 페이지 만들기" 수준으로 목표를 극단적으로 잘게 쪼개도록 유도합니다.
|
||||
- **효과:** 시작의 허들(Activation Energy)을 바닥까지 낮춰줍니다.
|
||||
|
||||
### C. Commitment Ritual (몰입 세팅과 선언)
|
||||
`/space`에 들어가서 환경을 바꾸는 것이 아니라, `/app`에서 오늘 나를 도와줄 환경을 선택하고 '입장'하는 것이 하나의 의식이 되어야 합니다.
|
||||
- **기능:** 목표를 정한 후, 오늘 나의 기분에 맞는 **Vibe(배경 씬 + 백색소음)**와 **Pace(뽀모도로 25분 / 딥워크 50분 등)**를 선택합니다.
|
||||
- **UX:** 세팅을 마치고 "집중 모드 진입하기" 버튼을 누를 때, 시각적/청각적인 전환 효과와 함께 비행기가 이륙하듯 `/space`로 부드럽게 빨려 들어가는 연출을 줍니다.
|
||||
|
||||
### D. Momentum & Streak (과거의 작은 성공 시각화)
|
||||
- **기능:** `/app` 화면 한 켠에, 최근에 내가 해냈던 작은 몰입 세션들의 기록(잔디 심기, 자라나는 식물 등)을 시각적이고 감성적으로 보여줍니다.
|
||||
- **효과:** "어제도 해냈으니 오늘도 할 수 있다"는 자기 효능감을 심어주어 도파민을 자극합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 비즈니스 모델(BM): 기꺼이 돈을 내게 만드는 수익화 전략
|
||||
|
||||
유저가 제품에 깊이 몰입하고 나면, 다음과 같은 **PRO 플랜(구독 모델)**을 통해 수익화를 달성할 수 있습니다.
|
||||
|
||||
1. **AI Micro-Stepping Coach (AI 목표 쪼개기 및 코칭)**
|
||||
- **무료:** 유저가 직접 첫 단계를 고민해서 적어야 함.
|
||||
- **PRO:** "기획서 쓰기"라고 치면, AI가 [1. 레퍼런스 3개 찾기, 2. 목차 5개 적기, 3. 서론 쓰기] 등으로 즉각 분해해주고 응원의 메시지를 건넴. (ADHD 유저에게 압도적인 가치 제공)
|
||||
2. **Premium Vibes (독점 씬 & 사운드스케이프)**
|
||||
- **무료:** 기본 자연음(숲, 비투비) 2~3종 제공.
|
||||
- **PRO:** 유명 아티스트가 작곡한 Lo-Fi 집중 비트, 세계 유명 도서관/카페의 3D 공간 음향, 초고화질 동적 배경(비 오는 도쿄 야경 등) 무제한 접근.
|
||||
3. **Deep Focus Analytics (심층 분석 리포트)**
|
||||
- **무료:** 오늘 하루 총 집중 시간(단순 타이머 기록)만 확인 가능.
|
||||
- **PRO:** 웹 서비스의 한계를 극복하기 위해 **세션 종료 후 유저가 직접 평가하는 '집중도 점수(1~5점)'와 Page Visibility API를 활용한 '탭 이탈(딴짓) 횟수 기록'을 결합**합니다. 이를 통해 단순히 켜둔 시간이 아니라 어떤 사운드/환경에서 가장 '질 높은' 집중을 했는지, 요일별 언제가 집중 Peak Time인지 분석하여 나만의 최적화된 루틴을 제안합니다.
|
||||
4. **Calendar & Notion Integration (업무 툴 연동)**
|
||||
- **PRO:** 구글 캘린더의 일정이나 노션의 Task를 `/app`의 Brain Dump로 자동 불러오고, 집중 세션이 끝나면 소요 시간과 함께 자동으로 기록/동기화됨. (프리랜서들의 필수 니즈)
|
||||
- **기술적 실현 가능성 (Feasibility Check):** 웹 서비스 환경에서도 **OAuth 2.0 기반의 Google Calendar API 및 Notion API**를 연동하여 완벽히 구현 가능합니다. 서버에서 유저의 접근 권한 토큰을 관리하여 웹 클라이언트와 외부 서비스 간의 양방향 데이터 동기화를 지원할 수 있습니다.
|
||||
|
||||
## 요약 (Next Action)
|
||||
`/app`은 단순히 데이터를 넘기는 껍데기가 아니라, **유저의 마음을 달래고(Brain Dump), 허들을 낮춰주며(Micro-Stepping), 몰입을 세팅(Ritual)하는 심리적 안전기지**가 되어야 합니다. 이 경험이 탄탄해지면 유저는 VibeRoom을 단순한 타이머가 아닌 '나의 루틴 파트너'로 인식하게 되며, AI 코칭과 프리미엄 환경을 위해 기꺼이 지갑을 열게 될 것입니다.
|
||||
544
docs/screens/app/current/19_app_atmosphere_entry_spec.md
Normal file
544
docs/screens/app/current/19_app_atmosphere_entry_spec.md
Normal file
@@ -0,0 +1,544 @@
|
||||
# 19. `/app` Atmosphere Entry Spec
|
||||
|
||||
Last Updated: 2026-03-16
|
||||
|
||||
이 문서는 `/app`을 **`goal + duration + atmosphere` 중심의 premium focus entry surface**로 재설계하기 위한 기준 문서다.
|
||||
|
||||
현재 상태:
|
||||
|
||||
- `Slice 1` no-session shell 구현 완료
|
||||
- `Slice 1-2` visual premium polish 구현 완료
|
||||
- `Custom Duration Contract` 구현 완료
|
||||
- `Weekly Review Dock Reposition` 구현 완료
|
||||
- 다음 slice는 `/space` current-session-only cleanup과 browser QA다
|
||||
|
||||
핵심 정책 변경:
|
||||
|
||||
- `/app`에는 더 이상 running / paused / resume 같은 session gate UI를 두지 않는다
|
||||
- current session이 있으면 상태와 상관없이 바로 `/space`로 보낸다
|
||||
- current session이 없을 때만 `/app` entry shell이 열린다
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `../../../../../product_principles.md`
|
||||
- `../../../../../current_context.md`
|
||||
- `../../stats/current/14_weekly_review_reframe_spec.md`
|
||||
- `../../../flows/current/15_app_stats_entry_flow_spec.md`
|
||||
- `../../../flows/current/18_paused_session_reentry_spec.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 왜 다시 바꾸는가
|
||||
|
||||
현재 `/app`은 single-goal commitment gate로 정리되어 있지만,
|
||||
사용자가 실제로 “어떤 환경으로 들어갈지”를 고르는 감각 품질이 entry에서 충분히 살아 있지 않다.
|
||||
|
||||
문제:
|
||||
|
||||
- 시작은 빨라졌지만, entry가 아직 너무 추상적이다
|
||||
- `scene / sound / timer`가 제품 핵심 가치인데 `/app`에서는 거의 느껴지지 않는다
|
||||
- 첫 진입에서 VibeRoom만의 premium 감각이 충분히 전달되지 않는다
|
||||
|
||||
따라서 `/app`은 다시 planning home으로 돌아가면 안 되지만,
|
||||
**`goal + duration + atmosphere`를 한 화면에서 직관적으로 결정하는 premium entry stage**로 진화할 필요가 있다.
|
||||
|
||||
핵심은 이것이다.
|
||||
|
||||
> `/app`은 여전히 한 가지 목표만 정하는 화면이어야 하지만,
|
||||
> 그 목표를 어떤 분위기에서 얼마나 붙잡을지까지 함께 결정하는 stage가 되어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 한 줄 정의
|
||||
|
||||
`/app`은 사용자가
|
||||
|
||||
1. 이번 세션의 목표를 한 줄로 적고
|
||||
2. 이 목표에 얼마나 걸릴지 직접 입력하고
|
||||
3. 배경 + 사운드가 묶인 하나의 `Atmosphere`를 고른 뒤
|
||||
4. 바로 `/space`로 들어가는
|
||||
|
||||
**premium focus staging screen**이다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 제품 원칙
|
||||
|
||||
### 0. `/app`은 session 상태를 보여주지 않는다
|
||||
|
||||
- `/app`은 entry surface다
|
||||
- current session이 있으면 사용자를 붙잡아 두지 않고 바로 `/space`로 이동시킨다
|
||||
- 따라서 `/app` 안의 paused resume gate, takeover sheet, session review entry는 current가 아니다
|
||||
|
||||
### 1. Single Goal First는 유지한다
|
||||
|
||||
- goal은 1개만 입력한다
|
||||
- entry에서 multi-goal / list / planner affordance 금지
|
||||
|
||||
### 2. MicroStep은 `/app`에서 제거한다
|
||||
|
||||
- 최초 entry에서는 microStep을 묻지 않는다
|
||||
- microStep은 `/space` 안의 recovery / refocus에서 다시 잡는다
|
||||
- 이유:
|
||||
- 첫 entry는 시작 마찰을 최소화해야 한다
|
||||
- goal, duration, atmosphere만으로도 결정량이 충분하다
|
||||
|
||||
### 3. Duration은 사용자가 직접 입력한다
|
||||
|
||||
- 기존 preset `25/5`, `50/10`, `90/20` 중심 entry를 버린다
|
||||
- 사용자는 “이 목표를 끝내는 데 얼마나 걸릴지”를 분 단위로 적는다
|
||||
- 예: `70분`
|
||||
|
||||
### 4. 배경 + 사운드는 하나의 오브젝트로 다룬다
|
||||
|
||||
- scene과 sound를 따로 고르게 하지 않는다
|
||||
- entry에서 사용자가 고르는 것은 `Atmosphere` 하나다
|
||||
- 즉 `/app`의 선택 단위는 `scene + sound pair`
|
||||
|
||||
### 5. Review는 entry를 방해하지 않는 secondary ritual이어야 한다
|
||||
|
||||
- `/app`의 주인공은 시작이다
|
||||
- weekly review, achieved goals, “어떤 조합에서 잘 됐는지”는 보이되 start보다 앞서면 안 된다
|
||||
|
||||
---
|
||||
|
||||
## 4. 용어 정의
|
||||
|
||||
### 추천 명칭: `Atmosphere`
|
||||
|
||||
배경 + 사운드가 결합된 카드의 공식 명칭은 `Atmosphere`로 고정한다.
|
||||
|
||||
이유:
|
||||
|
||||
- scene, sound, timer보다 더 제품적인 단위로 읽힌다
|
||||
- “배경 카드”보다 premium하고 감성적이다
|
||||
- 나중에 Pro 가치(`saved atmospheres`, `best atmosphere for mornings`)와도 자연스럽게 연결된다
|
||||
|
||||
### UI에서의 노출 방식
|
||||
|
||||
- 섹션 제목: `어떤 분위기에서 들어갈까요?`
|
||||
- 카드 타입 명칭: `Atmosphere`
|
||||
- 예시 카드명:
|
||||
- Rain Window
|
||||
- Quiet Library
|
||||
- Forest Draft
|
||||
- Deep Night Desk
|
||||
|
||||
### 피해야 할 이름
|
||||
|
||||
- `배경 카드`
|
||||
- `사운드 카드`
|
||||
- `조합 카드`
|
||||
- `프리셋`
|
||||
|
||||
이런 이름은 값싸 보이거나 툴처럼 읽힌다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 정보 구조
|
||||
|
||||
`/app`은 아래 3층으로 구성한다.
|
||||
|
||||
### Layer 1. Entry Header
|
||||
|
||||
- VibeRoom wordmark
|
||||
- quiet weekly review entry
|
||||
- plan pill / account
|
||||
|
||||
전제:
|
||||
|
||||
- current session이 있으면 이 화면 자체에 오래 머무르지 않고 `/space`로 넘어간다
|
||||
|
||||
### Layer 2. Primary Start Stage
|
||||
|
||||
- goal input
|
||||
- duration input
|
||||
- start CTA
|
||||
- editorial copy block
|
||||
- selected atmosphere large preview stage
|
||||
|
||||
### Layer 3. Atmosphere Grid
|
||||
|
||||
- 12개의 curated atmosphere card
|
||||
- row당 4개
|
||||
- 첫 구현은 더미 데이터
|
||||
|
||||
핵심은
|
||||
**review / archive / history가 main stage를 먹지 않고, atmosphere grid도 secondary decoration이 아니라 실제 선택 surface로 작동해야 한다**는 점이다.
|
||||
|
||||
### 현재 visual shell 원칙
|
||||
|
||||
- left: decision rail
|
||||
- 큰 제목
|
||||
- goal / duration input
|
||||
- selected atmosphere summary
|
||||
- primary CTA
|
||||
- quiet review dock
|
||||
- right: selected atmosphere stage
|
||||
- immersive preview
|
||||
- sound / scene / best-for meta
|
||||
- bottom: curated atmosphere library
|
||||
- 4열 grid
|
||||
- selected state가 즉시 읽혀야 함
|
||||
|
||||
---
|
||||
|
||||
## 6. 화면 구성
|
||||
|
||||
### A. Goal Input
|
||||
|
||||
- 가장 위, 가장 크게
|
||||
- 한 줄 입력
|
||||
- placeholder 예시:
|
||||
- `예: 여행 계획 짜기`
|
||||
- `예: 제안서 첫 문단 정리`
|
||||
|
||||
규칙:
|
||||
|
||||
- 필수
|
||||
- 한 줄
|
||||
- Enter로 start 가능
|
||||
|
||||
### B. Duration Input
|
||||
|
||||
- goal 바로 아래 또는 옆
|
||||
- 분 단위 숫자 입력
|
||||
- label:
|
||||
- `얼마나 붙잡을까요?`
|
||||
- `예상 시간(분)`
|
||||
|
||||
규칙:
|
||||
|
||||
- 필수
|
||||
- 숫자만
|
||||
- 권장 범위:
|
||||
- 최소 5분
|
||||
- 최대 180분
|
||||
- helper:
|
||||
- `이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.`
|
||||
- quick duration suggestion은 허용한다.
|
||||
- 예: `25`, `45`, `70`, `90`
|
||||
- 단, planner처럼 보이지 않도록 아주 조용한 assistive chip이어야 한다
|
||||
|
||||
### C. Atmosphere Grid
|
||||
|
||||
- 12개 dummy card
|
||||
- desktop: 4열 x 3행
|
||||
- tablet: 3열
|
||||
- mobile: 2열
|
||||
|
||||
각 카드 구성:
|
||||
|
||||
- 배경 썸네일
|
||||
- 카드명
|
||||
- sound label
|
||||
- 1줄 description
|
||||
- selected state
|
||||
- hover / focus state
|
||||
|
||||
예시:
|
||||
|
||||
- `Rain Window` / `Soft Rain`
|
||||
- `Quiet Library` / `Air + Page Rustle`
|
||||
- `Forest Draft` / `Forest Birds`
|
||||
- `Deep Night Desk` / `Low White`
|
||||
|
||||
### D. Start CTA
|
||||
|
||||
- 문구 예시:
|
||||
- `이 분위기로 들어가기`
|
||||
- `지금 시작`
|
||||
|
||||
추천:
|
||||
|
||||
- 기본 CTA는 `이 분위기로 들어가기`
|
||||
- 이유:
|
||||
- goal + duration + atmosphere가 모두 한 번에 묶여 entry action으로 읽힌다
|
||||
|
||||
### E. Selection Rules
|
||||
|
||||
- no-session 상태에서는 atmosphere 1개가 기본 선택된 상태로 시작한다
|
||||
- review handoff로 들어온 경우에는 handoff `entryAtmosphereId + entryDurationMinutes`를 preselect한다
|
||||
- 사용자가 duration을 직접 수정하기 전까지는 선택된 atmosphere의 기본 duration suggestion을 보여줄 수 있다
|
||||
- 사용자가 duration을 직접 수정한 뒤에는 atmosphere를 바꿔도 duration 값을 덮어쓰지 않는다
|
||||
|
||||
### F. Weekly Review Entry
|
||||
|
||||
이건 main stage 바깥의 quiet secondary placement로 둔다.
|
||||
|
||||
권장 위치:
|
||||
|
||||
- desktop: header 아래 우상단 quiet insight panel
|
||||
- mobile: hero 하단의 얇은 secondary card
|
||||
|
||||
보여줄 것:
|
||||
|
||||
- 이번 주 가장 잘 맞았던 atmosphere
|
||||
- 시작 성공률 또는 복귀율 1줄
|
||||
- CTA: `이번 주 review`
|
||||
|
||||
보이지 말아야 할 것:
|
||||
|
||||
- achieved goals 리스트 전체
|
||||
- 작은 차트 여러 개
|
||||
- dashboard처럼 보이는 summary wall
|
||||
|
||||
---
|
||||
|
||||
## 7. Weekly Review / Achieved Goals를 어디에 둘 것인가
|
||||
|
||||
결론:
|
||||
|
||||
> `/app`에는 full review를 두지 않고,
|
||||
> `quiet review dock`만 둔다.
|
||||
> 자세한 achieved goals와 “어떤 조합에서 잘 됐는지”는 `/stats`에서 본다.
|
||||
|
||||
### 왜 `/app`에 다 보여주면 안 되는가
|
||||
|
||||
- 메인 entry의 집중력이 깨진다
|
||||
- goal 입력과 review 해석이 같은 레벨로 보인다
|
||||
- planner/dashboard처럼 읽힌다
|
||||
|
||||
### 추천 구조
|
||||
|
||||
#### `/app`
|
||||
|
||||
- 작은 weekly review dock
|
||||
- 보여주는 정보는 1~2줄만
|
||||
- 예:
|
||||
- `이번 주엔 Rain Window에서 가장 오래 이어졌어요.`
|
||||
- `여행 계획 같은 넓은 목표는 70분보다 45분에서 더 잘 닫혔어요.`
|
||||
|
||||
#### `/stats`
|
||||
|
||||
- achieved goals history
|
||||
- best-performing atmospheres
|
||||
- duration fit
|
||||
- recovery quality
|
||||
|
||||
즉:
|
||||
|
||||
- `/app`은 “review를 여는 문”
|
||||
- `/stats`는 “review desk”
|
||||
|
||||
---
|
||||
|
||||
## 8. 상태별 유저 플로우
|
||||
|
||||
### Flow A. First Entry / No Session
|
||||
|
||||
1. 사용자가 `/app` 진입
|
||||
2. goal 입력
|
||||
3. duration 입력
|
||||
4. atmosphere 1개 선택
|
||||
5. CTA 클릭
|
||||
6. `/space` 진입
|
||||
|
||||
핵심:
|
||||
|
||||
- microStep 없음
|
||||
- list 없음
|
||||
- wizard 없음
|
||||
|
||||
### Flow B. Running Session Exists
|
||||
|
||||
1. 사용자가 `/app` 진입
|
||||
2. 바로 `/space` redirect
|
||||
|
||||
이유:
|
||||
|
||||
- 이미 실행 중인 세션은 다시 decision gate에 세우지 않는다
|
||||
|
||||
### Flow C. Any Current Session Exists
|
||||
|
||||
1. 사용자가 `/app` 진입
|
||||
2. 바로 `/space` redirect
|
||||
|
||||
중요:
|
||||
|
||||
- `/app`은 current session을 처리하는 장소가 아니다
|
||||
- resume / pause / break / complete 판단은 `/space` 안에서 이어진다
|
||||
|
||||
### Flow D. Review Entry
|
||||
|
||||
1. 사용자가 `/app`의 quiet review dock 클릭
|
||||
2. `/stats`
|
||||
3. carry-forward CTA
|
||||
4. 다시 `/app`
|
||||
|
||||
---
|
||||
|
||||
## 9. Visual Direction
|
||||
|
||||
### Primary Stage
|
||||
|
||||
- no-session `/app`의 주인공은 하나의 넓은 start stage다
|
||||
- 이 stage 안에서:
|
||||
- goal
|
||||
- duration
|
||||
- primary CTA
|
||||
가 먼저 읽혀야 한다
|
||||
- stage는 glassmorphism을 쓰더라도 dashboard 카드처럼 보이면 안 된다
|
||||
- typography와 spacing으로 premium하게 보여야 한다
|
||||
|
||||
### Atmosphere Grid
|
||||
|
||||
- 아래 grid는 decoration이 아니라 실제 선택 surface다
|
||||
- card는 서로 다른 장면으로 충분히 구분돼야 한다
|
||||
- selected state는:
|
||||
- 두꺼운 outline보다 얇은 light ring
|
||||
- 미세한 scale
|
||||
- title/sound contrast 상승
|
||||
정도로 표현하는 편이 맞다
|
||||
|
||||
### Review Dock
|
||||
|
||||
- review는 top-right quiet dock 또는 stage 바깥의 조용한 secondary panel로 둔다
|
||||
- 절대 main CTA와 같은 무게가 되면 안 된다
|
||||
- “이번 주 review”는 읽히되, goal 입력을 방해하면 실패다
|
||||
|
||||
---
|
||||
|
||||
## 10. 구현 상태와 남은 작업
|
||||
|
||||
현재 `/app`의 no-session shell은 이미 구현되어 있고, focus-session API V2도 함께 연결됐다.
|
||||
|
||||
현재 포함된 범위:
|
||||
|
||||
- goal input
|
||||
- duration input
|
||||
- 12개 atmosphere grid
|
||||
- selected atmosphere 기반 background 반영
|
||||
- quiet review dock의 기본 위치
|
||||
- raw `focusDurationMinutes` start payload
|
||||
- review handoff의 `entryAtmosphereId + entryDurationMinutes`
|
||||
|
||||
이번 라운드에서 제외된 것:
|
||||
|
||||
- `/stats` IA 변경
|
||||
- `/space` current-session-only dead path cleanup
|
||||
|
||||
### 현재 계약
|
||||
|
||||
- 사용자는 분 단위 duration을 입력한다
|
||||
- server는 `focusDurationMinutes`를 공식 start 계약으로 받는다
|
||||
- selected atmosphere는 `atmosphereId + sceneId + soundPresetId`로 함께 저장된다
|
||||
- `/space`는 current session 응답의 `focusDurationSeconds`, `atmosphereId`, `sceneId`, `soundPresetId`를 source of truth로 읽는다
|
||||
|
||||
---
|
||||
|
||||
## 9. UI 방향
|
||||
|
||||
### 톤
|
||||
|
||||
- premium
|
||||
- calm
|
||||
- stage-like
|
||||
- card-heavy dashboard 금지
|
||||
|
||||
### 중요한 시각 원칙
|
||||
|
||||
- 상단 input stage는 “form”보다 “entry surface”처럼 보여야 한다
|
||||
- atmosphere grid는 catalog처럼 보이되, ecommerce처럼 보이면 안 된다
|
||||
- card는 selection object이지 콘텐츠 카드가 아니다
|
||||
- hover, selected, focus state가 매우 명확해야 한다
|
||||
|
||||
### Selected Card
|
||||
|
||||
- 다른 카드보다 1단 더 선명
|
||||
- subtle border glow
|
||||
- sound label이 더 읽히게
|
||||
- 선택되면 CTA 문구가 `이 분위기로 들어가기`와 연결돼야 함
|
||||
|
||||
### Unselected Card
|
||||
|
||||
- 너무 장식적이면 안 된다
|
||||
- 배경 썸네일이 주인공
|
||||
- 텍스트는 간결
|
||||
|
||||
---
|
||||
|
||||
## 10. 비즈니스 모델 관점
|
||||
|
||||
이 redesign은 BM에도 직접 연결된다.
|
||||
|
||||
### Free
|
||||
|
||||
- curated atmosphere 12개 제공
|
||||
- goal + custom duration + basic review dock
|
||||
- best atmosphere insight는 1줄만
|
||||
|
||||
### Pro
|
||||
|
||||
- personalized atmosphere recommendations
|
||||
- saved favorite atmospheres
|
||||
- goal type / time-of-day 기반 추천
|
||||
- “이 목표 유형에서 잘 맞았던 조합” 인사이트
|
||||
- duration suggestion
|
||||
- deeper review
|
||||
|
||||
즉 Pro는 “카드를 더 많이 준다”가 아니라
|
||||
**더 잘 맞는 atmosphere를 더 빨리 고르게 해준다**가 되어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 11. 서버 / 데이터 영향
|
||||
|
||||
이번 redesign은 backend contract까지 건드린다.
|
||||
|
||||
### 필요한 변화
|
||||
|
||||
1. `startSession`이 `focusDurationMinutes`를 공식 필드로 받는다
|
||||
2. session에 `focusDurationSeconds`와 `atmosphereId`를 저장한다
|
||||
3. `GET /focus-sessions/current`가 `/space` hydration에 필요한 goal / duration / atmosphere / scene / sound를 모두 반환한다
|
||||
4. `/stats -> /app` handoff는 preset이 아니라 atmosphere / duration 힌트를 사용한다
|
||||
|
||||
---
|
||||
|
||||
## 12. 구현 순서
|
||||
|
||||
### Slice 1. Atmosphere Model / Dummy Data
|
||||
|
||||
- atmosphere card dummy 12개 정의
|
||||
- 카드명 / scene / sound / thumbnail 연결
|
||||
|
||||
### Slice 2. `/app` Entry Shell Redesign
|
||||
|
||||
- goal input
|
||||
- duration input
|
||||
- 4x3 atmosphere grid
|
||||
- selected card state
|
||||
- start CTA
|
||||
|
||||
### Slice 3. Paused Gate Coexistence
|
||||
|
||||
- current session이 있으면 `/app`을 건너뛰고 `/space`로 보낸다
|
||||
- no-session일 때만 새 shell 노출
|
||||
|
||||
### Slice 4. Custom Duration Contract
|
||||
|
||||
- web -> server `custom minutes`
|
||||
- break duration policy
|
||||
- `/space` timer HUD와 연동
|
||||
|
||||
### Slice 5. Weekly Review Dock
|
||||
|
||||
- `/app`의 quiet secondary review entry를 새 shell에 맞게 재배치
|
||||
- `/stats` entry/handoff 유지
|
||||
|
||||
### Slice 6. Pro Personalization
|
||||
|
||||
- recommended atmosphere
|
||||
- best-fit insight
|
||||
- duration suggestion
|
||||
|
||||
---
|
||||
|
||||
## 13. 검증 기준
|
||||
|
||||
- 사용자가 `/app`에서 10초 안에 goal + duration + atmosphere를 정하고 들어갈 수 있다
|
||||
- atmosphere grid가 decorator가 아니라 실제 선택 surface로 읽힌다
|
||||
- weekly review가 start보다 앞서지 않는다
|
||||
- `/app`이 planner/dashboard처럼 보이지 않는다
|
||||
- current session이 있을 때는 `/app`에 머무르지 않고 `/space`로 이동한다
|
||||
- `70분` 같은 custom duration이 실제 세션 길이로 반영된다
|
||||
18
docs/screens/space/README.md
Normal file
18
docs/screens/space/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# `/space` Docs
|
||||
|
||||
`/space` 화면 자체 구조와 시각 규칙 문서를 모아둔 폴더다.
|
||||
|
||||
## current
|
||||
|
||||
- [13_space_intent_card_collapsed_expanded_spec.md](./current/13_space_intent_card_collapsed_expanded_spec.md)
|
||||
|
||||
## archive
|
||||
|
||||
- 현재 없음
|
||||
|
||||
## 같이 볼 문서
|
||||
|
||||
`/space`는 recovery flow 문서와 함께 읽어야 한다.
|
||||
|
||||
- [../../flows/current/10_refocus_system_spec.md](../../flows/current/10_refocus_system_spec.md)
|
||||
- [../../flows/current/11_away_return_recovery_spec.md](../../flows/current/11_away_return_recovery_spec.md)
|
||||
@@ -0,0 +1,273 @@
|
||||
# 13. `/space` Intent Card Collapsed / Expanded Spec
|
||||
|
||||
Last Updated: 2026-03-15
|
||||
|
||||
이 문서는 `/space` 좌상단 목표 카드의 **collapsed / expanded 구조**를 정의한다.
|
||||
|
||||
목적은 단순하다.
|
||||
|
||||
- goal은 항상 보여야 한다
|
||||
- 하지만 배경을 계속 크게 가리면 안 된다
|
||||
- 평소에는 조용한 `anchor`처럼 남고, 필요할 때만 `card`처럼 확장돼야 한다
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `./10_refocus_system_spec.md`
|
||||
- `./11_away_return_recovery_spec.md`
|
||||
- `../../product/12_core_loop_execution_roadmap.md`
|
||||
- `../../current_context.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 왜 바꾸는가
|
||||
|
||||
현재 구조의 문제는 이것이다.
|
||||
|
||||
- goal, microStep, 액션이 항상 한 카드 안에 모두 보인다
|
||||
- 읽힘은 확보하지만, 배경의 존재감을 과하게 가져간다
|
||||
- `/space`의 주인공이 scene이어야 하는데, 좌상단 카드가 먼저 읽힌다
|
||||
|
||||
즉 문제는 glass material 자체보다 **항상 큰 상태로 떠 있는 면적**이다.
|
||||
|
||||
premium ambient UI에서는:
|
||||
|
||||
- 정보는 항상 남아야 하지만
|
||||
- 존재감은 항상 같을 필요가 없다
|
||||
|
||||
그래서 목표 카드는 `persistent presence`는 유지하고, `persistent emphasis`는 버려야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 한 줄 정의
|
||||
|
||||
> `/space`의 목표 카드는 평소에는 얇은 glass rail로 남고, 사용자가 실제로 결정을 내려야 할 때만 확장된다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 상태 정의
|
||||
|
||||
Intent Card는 아래 2개 상태만 가진다.
|
||||
|
||||
### 3.1 Collapsed
|
||||
|
||||
기본 상태.
|
||||
|
||||
보이는 것:
|
||||
|
||||
- goal 1줄
|
||||
|
||||
보이지 않는 것:
|
||||
|
||||
- microStep 상세
|
||||
- 완료 액션
|
||||
- recovery footer
|
||||
|
||||
역할:
|
||||
|
||||
- 지금 어떤 일 위에 있는지 잃지 않게 하는 기준점
|
||||
|
||||
감정:
|
||||
|
||||
- panel이 아니라 anchor
|
||||
|
||||
### 3.2 Expanded
|
||||
|
||||
필요할 때만 열리는 상태.
|
||||
|
||||
보이는 것:
|
||||
|
||||
- goal
|
||||
- microStep 또는 비어 있을 때의 조용한 helper line
|
||||
- `이번 목표 완료`
|
||||
- microStep 완료 체크
|
||||
|
||||
역할:
|
||||
|
||||
- 지금 이 세션을 어떻게 계속할지 결정하게 하는 짧은 제어 면
|
||||
|
||||
감정:
|
||||
|
||||
- 툴바가 아니라, 잠깐 열리는 soft control surface
|
||||
|
||||
---
|
||||
|
||||
## 4. 상태 전환 규칙
|
||||
|
||||
### Expanded로 전환되는 경우
|
||||
|
||||
- desktop에서 hover
|
||||
- focus 진입
|
||||
- rail 전체 클릭/tap
|
||||
|
||||
### Collapsed로 돌아가는 경우
|
||||
|
||||
- pointer leave
|
||||
- focus out
|
||||
- expand affordance 재클릭
|
||||
- expanded 상태에서 card 바깥 pointer down
|
||||
- recovery tray가 열릴 때
|
||||
|
||||
중요:
|
||||
|
||||
- `pause / return / next-beat / complete / refocus` tray가 열려 있는 동안에는 목표 카드는 강제로 collapsed 상태를 유지한다
|
||||
- tray가 이미 의사결정 레이어이기 때문에, base card까지 expanded 상태면 배경을 이중으로 가리게 된다
|
||||
|
||||
### dismissal rule
|
||||
|
||||
- expanded goal card는 `outside click / outside tap`으로 접혀도 된다
|
||||
- 이 동작은 base card의 ephemeral expansion에만 적용한다
|
||||
- `refocus / next-beat / return / complete`처럼 의사결정을 요구하는 tray는 outside click으로 닫히면 안 된다
|
||||
- decision tray는 반드시 명시적 액션으로만 닫는다
|
||||
- `취소`
|
||||
- `적용`
|
||||
- `여기서 마무리하기`
|
||||
- `잠시 비우기`
|
||||
- `다음 블록 이어가기`
|
||||
- 즉, `/space`에서 가볍게 접히는 것은 `expanded rail`뿐이고, 실질적인 state change layer는 dismissible popover로 취급하지 않는다
|
||||
|
||||
---
|
||||
|
||||
## 5. 시각 구조
|
||||
|
||||
### Collapsed rail
|
||||
|
||||
- 폭: 약 `18rem ~ 19rem`
|
||||
- 높이: 약 `48px ~ 52px`
|
||||
- radius: `18px` 전후
|
||||
- glass:
|
||||
- 더 얇고 더 투명
|
||||
- large panel처럼 보이면 안 된다
|
||||
- goal:
|
||||
- 1줄 truncate
|
||||
- medium weight
|
||||
- affordance:
|
||||
- 별도 chevron / dropdown button은 두지 않는다
|
||||
- rail 자체가 expand trigger로 동작한다
|
||||
- desktop에서는 hover로, mobile에서는 tap으로 자연스럽게 열린다
|
||||
|
||||
### Expanded card
|
||||
|
||||
- 폭: 약 `21rem ~ 23rem`
|
||||
- 높이: 내용에 따라 자연스럽게
|
||||
- radius: `22px ~ 24px`
|
||||
- glass:
|
||||
- collapsed보다 한 단계 더 선명한 재질
|
||||
- 하지만 tray만큼 무겁지는 않다
|
||||
- 내부 구조:
|
||||
1. goal row
|
||||
2. microStep row
|
||||
3. quiet footer action
|
||||
|
||||
---
|
||||
|
||||
## 6. 정보 구조
|
||||
|
||||
### Goal row
|
||||
|
||||
- 좌측: goal 1줄
|
||||
- 우측: expanded 상태에서만 보이는 `수정` 액션
|
||||
- collapsed 상태에서는 rail 전체가 expand trigger로만 동작한다
|
||||
- expanded 상태에서도 goal 텍스트 자체는 edit trigger가 아니다
|
||||
- refocus는 expanded 상태의 명시적 `수정` 액션으로만 진입한다
|
||||
|
||||
### MicroStep row
|
||||
|
||||
- expanded 상태에서만 노출
|
||||
- 왼쪽: completion glyph
|
||||
- 오른쪽: microStep text
|
||||
- microStep이 없으면:
|
||||
- helper 1줄만 노출
|
||||
- planner-like placeholder 금지
|
||||
|
||||
### Footer action
|
||||
|
||||
- expanded 상태에서만 노출
|
||||
- 우측 정렬된 quiet text action 1개
|
||||
- `이번 목표 완료`
|
||||
- `다시 방향` 상시 버튼은 두지 않는다
|
||||
- refocus는 expanded 상태의 명시적 `수정` 액션으로만 진입한다
|
||||
|
||||
---
|
||||
|
||||
## 7. interaction 원칙
|
||||
|
||||
### Goal 수정
|
||||
|
||||
- expanded 상태의 `수정` 액션 클릭 -> refocus
|
||||
- collapsed 상태의 rail 클릭은 절대 refocus를 열지 않는다
|
||||
- 핵심은 `expand`와 `edit`의 역할이 섞이지 않게 하는 것이다
|
||||
|
||||
### microStep 완료
|
||||
|
||||
- expanded 상태에서만 완료 glyph가 노출된다
|
||||
- collapsed 상태에서는 실수 클릭을 막기 위해 숨긴다
|
||||
|
||||
### 목표 완료
|
||||
|
||||
- expanded 상태에서만 footer action 노출
|
||||
- collapsed 상태에서는 보이지 않는다
|
||||
|
||||
---
|
||||
|
||||
## 8. motion 원칙
|
||||
|
||||
### Collapsed -> Expanded
|
||||
|
||||
- 넓어짐 + 내부 row fade-in
|
||||
- card가 “튀어나오는” 느낌보다, rail이 조용히 열리는 느낌이어야 한다
|
||||
- duration은 짧고 부드럽게
|
||||
|
||||
### Expanded -> Collapsed
|
||||
|
||||
- 내부 row가 먼저 약해지고
|
||||
- 폭이 줄어든다
|
||||
- 닫힘은 더 조용해야 한다
|
||||
|
||||
### tray open 시
|
||||
|
||||
- intent card는 먼저 collapsed
|
||||
- 그 아래 tray가 열림
|
||||
- 즉 `card 확장`과 `tray 확장`은 동시에 크게 보이면 안 된다
|
||||
|
||||
---
|
||||
|
||||
## 9. 금지사항
|
||||
|
||||
- 상시 expanded 금지
|
||||
- goal + microStep + footer action 항상 노출 금지
|
||||
- Now chip 재도입 금지
|
||||
- toolbar/pill/button cluster처럼 보이게 만들기 금지
|
||||
- 배경보다 card가 먼저 읽히게 만들기 금지
|
||||
- rail 클릭과 edit 진입을 같은 액션으로 섞기 금지
|
||||
- 별도 chevron/dropdown button을 상시 노출하기 금지
|
||||
|
||||
---
|
||||
|
||||
## 10. 구현 기준
|
||||
|
||||
필수 변경 파일:
|
||||
|
||||
- `src/widgets/space-focus-hud/ui/IntentCapsule.tsx`
|
||||
- `src/widgets/space-focus-hud/ui/SpaceFocusHudWidget.tsx`
|
||||
|
||||
가능한 보조 변경:
|
||||
|
||||
- `src/shared/i18n/messages/space.ts`
|
||||
- `src/widgets/space-focus-hud/ui/overlayStyles.ts`
|
||||
|
||||
구현 포인트:
|
||||
|
||||
- `IntentCapsule` 내부에 local expanded state 추가
|
||||
- hover / focus / toggle button 기반 전환
|
||||
- `showActions === false` 또는 overlay open 상태에서는 강제 collapsed
|
||||
- microStep / footer action은 expanded에서만 렌더
|
||||
|
||||
---
|
||||
|
||||
## 11. 완료 기준
|
||||
|
||||
- idle 상태에서 상단 goal UI가 배경을 덜 가린다
|
||||
- expanded 상태에서만 microStep과 완료 액션이 보인다
|
||||
- recovery tray가 열릴 때 base card는 과하게 커져 있지 않다
|
||||
- bright / dark scene 모두에서 읽히지만 scene이 먼저 읽힌다
|
||||
- `/space`가 productivity dashboard처럼 보이지 않는다
|
||||
17
docs/screens/stats/README.md
Normal file
17
docs/screens/stats/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# `/stats` Docs
|
||||
|
||||
`/stats` 화면의 review 구조 문서를 모아둔 폴더다.
|
||||
|
||||
## current
|
||||
|
||||
- [14_weekly_review_reframe_spec.md](./current/14_weekly_review_reframe_spec.md)
|
||||
|
||||
## archive
|
||||
|
||||
- 현재 없음
|
||||
|
||||
## 같이 볼 문서
|
||||
|
||||
`/stats`는 `/app` 진입/복귀 플로우와 함께 읽어야 한다.
|
||||
|
||||
- [../../flows/current/15_app_stats_entry_flow_spec.md](../../flows/current/15_app_stats_entry_flow_spec.md)
|
||||
636
docs/screens/stats/current/14_weekly_review_reframe_spec.md
Normal file
636
docs/screens/stats/current/14_weekly_review_reframe_spec.md
Normal file
@@ -0,0 +1,636 @@
|
||||
# 14. Weekly Review Reframe Spec
|
||||
|
||||
Last Updated: 2026-03-14
|
||||
|
||||
이 문서는 VibeRoom의 `Weekly Review`를 단순 통계 화면이 아니라
|
||||
**다음 세션의 성공률을 높이는 행동 시스템**으로 재정의하는 상세 기획 문서다.
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `../app/19_app_atmosphere_entry_spec.md`
|
||||
- `../space/10_refocus_system_spec.md`
|
||||
- `../space/11_away_return_recovery_spec.md`
|
||||
- `../../product/12_core_loop_execution_roadmap.md`
|
||||
- `../space/13_space_intent_card_collapsed_expanded_spec.md`
|
||||
- `../../product_principles.md`
|
||||
- `../../current_context.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 정의
|
||||
|
||||
현재 `/stats`는 factual summary 중심으로 정리되어 있다.
|
||||
|
||||
좋은 점:
|
||||
|
||||
- 과한 해석을 줄였다
|
||||
- mock insight와 가짜 코칭을 제거했다
|
||||
- started / completed / carried over / focus minutes 같은 실제 수치만 남겼다
|
||||
|
||||
부족한 점:
|
||||
|
||||
- 사용자가 `그래서 다음 주에 뭘 바꾸면 되는지`를 알 수 없다
|
||||
- 총 시간은 보여주지만 `왜 잘 되었는지 / 왜 무너졌는지`는 행동 수준에서 연결되지 않는다
|
||||
- Pro 가치가 `review`라는 이름에 비해 아직 약하다
|
||||
- 현재 구조는 여전히 `summary dashboard`에 가깝고, `behavior change surface`로는 약하다
|
||||
|
||||
핵심 문제는 이거다.
|
||||
|
||||
> 지금의 `/stats`는 지난 시간을 보여주지만, 다음 집중의 성공률을 높이지는 못한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 한 줄 정의
|
||||
|
||||
> Weekly Review는 지난 7일의 집중 기록을 예쁘게 보여주는 화면이 아니라, 다음 주의 첫 세션을 더 잘 시작하게 만드는 주간 정리 ritual이다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 제품 목표
|
||||
|
||||
Weekly Review가 해야 할 일은 4개뿐이다.
|
||||
|
||||
1. 내가 `시작`을 잘하는 조건을 보여준다
|
||||
2. 내가 `흔들린 뒤 복귀`를 잘하는 조건을 보여준다
|
||||
3. 내가 `완료`까지 가는 패턴을 보여준다
|
||||
4. 다음 주에 바꿀 것을 1~2개만 남긴다
|
||||
|
||||
하지 말아야 할 일도 분명하다.
|
||||
|
||||
- 예쁜 그래프 전시장 되기
|
||||
- 생산성 점수 놀이
|
||||
- 성격 분석처럼 보이는 과한 해석
|
||||
- planner / habit tracker / life dashboard로 확장
|
||||
|
||||
---
|
||||
|
||||
## 4. 시장 신호와 포지셔닝
|
||||
|
||||
2026-03-14 기준 공식 포지셔닝을 보면:
|
||||
|
||||
- [LifeAt pricing](https://lifeat.io/pricing)는 `daily planner`, `multiple calendars`, `unlimited todos and notes`, `advanced analytics`를 함께 판다.
|
||||
- [Focusmate pricing](https://www.focusmate.com/pricing/)는 무료 주 3회, 유료 무제한으로 `accountability access`를 판다.
|
||||
- [Portal](https://portal.app/)은 `immersive spatial audio`와 감각 품질을 전면에 둔다.
|
||||
- [Endel ADHD](https://endel.io/adhd)는 `Focus Timer`, `Task Headline`, `App Blocker`, active exercises를 ADHD 친화적 도구로 묶는다.
|
||||
- [Brain.fm pricing](https://www.brain.fm/pricing)는 `science-backed music` 자체를 프리미엄 가치로 판다.
|
||||
|
||||
VibeRoom의 Weekly Review는 LifeAt처럼 planner analytics로 가면 안 된다.
|
||||
또 Focusmate처럼 외부 accountability를 핵심으로 가도 안 된다.
|
||||
|
||||
VibeRoom의 review는 아래처럼 포지셔닝해야 한다.
|
||||
|
||||
- planner review가 아니다
|
||||
- performance dashboard도 아니다
|
||||
- `내가 어떤 조건에서 실제로 시작하고 복귀하는지`를 보여주는 focus quality review다
|
||||
|
||||
즉, review는 Pro에서 이렇게 팔아야 한다.
|
||||
|
||||
> 더 많은 일을 관리하는 리뷰가 아니라, 덜 흔들리고 더 빨리 다시 시작하게 해주는 리뷰
|
||||
|
||||
---
|
||||
|
||||
## 5. 사용자 정의
|
||||
|
||||
### Primary User
|
||||
|
||||
- ADHD 성향이 있거나 시작 마찰이 큰 프리랜서
|
||||
- 해야 할 일은 알지만 시작과 복귀가 어렵다
|
||||
- 감각 환경이 집중 품질에 영향을 준다
|
||||
- 통계는 싫지 않지만, 너무 많은 정보는 오히려 회피를 만든다
|
||||
|
||||
### Weekly Review에서의 실제 질문
|
||||
|
||||
- 나는 언제 시작이 잘 되었지?
|
||||
- 어떤 배경/사운드/길이에서 덜 무너졌지?
|
||||
- pause 후에 잘 돌아온 편인가?
|
||||
- 다음 주에는 뭘 하나만 바꾸면 될까?
|
||||
|
||||
---
|
||||
|
||||
## 6. Weekly Review 경험 원칙
|
||||
|
||||
### 1. Review는 지난 주를 닫고 다음 주를 여는 ritual이어야 한다
|
||||
|
||||
- 회고는 회고로 끝나면 안 된다
|
||||
- 반드시 다음 주의 첫 세션과 연결되어야 한다
|
||||
|
||||
### 2. 총 시간보다 행동 전환을 우선한다
|
||||
|
||||
- focus minutes는 보조 지표다
|
||||
- 핵심은 `start`, `recovery`, `completion`
|
||||
|
||||
### 3. 모든 해석은 근거가 있는 factual summary여야 한다
|
||||
|
||||
- “당신은 밤형 인간입니다” 같은 해석 금지
|
||||
- “최근 7일 중 오전 9시~12시 시작 세션의 완료율이 가장 높았어요”처럼
|
||||
raw metric에 직접 연결되는 문장만 허용
|
||||
|
||||
### 4. 한 화면에서 바꾸는 제안은 최대 2개만
|
||||
|
||||
- insight가 많아 보이는 것은 premium이 아니다
|
||||
- `다음 주엔 이 두 가지만 바꿔보자`가 더 premium하다
|
||||
|
||||
### 5. Review는 planner가 아니라 focus optimizer여야 한다
|
||||
|
||||
- todo completion analytics 금지
|
||||
- calendar productivity reporting 금지
|
||||
- session quality와 ritual fit만 본다
|
||||
|
||||
### 6. Free도 충분히 가치 있어야 하고, Pro는 더 깊어야 한다
|
||||
|
||||
- Free는 `기본적인 self-awareness`
|
||||
- Pro는 `행동 변화에 쓸 수 있는 pattern visibility`
|
||||
|
||||
---
|
||||
|
||||
## 7. 정보 구조
|
||||
|
||||
Weekly Review는 `/stats` 안에서 아래 5개 구역으로 재구성한다.
|
||||
|
||||
### 현재 visual shell 원칙
|
||||
|
||||
- `/stats`는 밝은 factual dashboard가 아니라 dark immersive review stage로 간다
|
||||
- 배경은 carry-forward ritual과 연결되는 atmosphere를 얇게 투영한다
|
||||
- 상단은 quiet accessory만 두고, hero summary가 화면의 중심이 된다
|
||||
- snapshot metrics는 작은 glass tile rail로, start/recovery/completion은 같은 family의 review panel로 통일한다
|
||||
- 마지막 carry-forward CTA는 별도 버튼 묶음이 아니라 다음 세션 entry로 자연스럽게 이어지는 closure stage여야 한다
|
||||
- `/app`의 entry ritual과는 다르게, `/stats`는 `observatory / signal board` 감각으로 읽혀야 한다
|
||||
- 즉 같은 glass family는 유지하되, 입력 ritual을 닮은 중앙 구조를 복제하지 않는다
|
||||
|
||||
### Section A. Weekly Snapshot
|
||||
|
||||
목적:
|
||||
|
||||
- 이번 주를 한눈에 닫아주는 첫 문장
|
||||
|
||||
구성:
|
||||
|
||||
- title: `이번 주 집중 리듬`
|
||||
- subline: `시작 9회 · 복귀 5회 · 완료 4회`
|
||||
- short summary 1줄
|
||||
|
||||
예시:
|
||||
|
||||
- `이번 주에는 시작은 자주 했고, pause 뒤 복귀는 절반 정도 이어졌어요.`
|
||||
- `짧은 세션보다 45분 이상 세션에서 완료율이 더 높았어요.`
|
||||
|
||||
규칙:
|
||||
|
||||
- summary는 1줄만
|
||||
- 과장 금지
|
||||
- 반드시 실제 수치에서 바로 읽히는 문장만 허용
|
||||
|
||||
### Section B. Start Quality
|
||||
|
||||
목적:
|
||||
|
||||
- 사용자가 `들어가는 힘`을 얼마나 잘 만들었는지 보여준다
|
||||
|
||||
노출 지표:
|
||||
|
||||
- started sessions
|
||||
- start days
|
||||
- first start consistency
|
||||
- planned start vs ad-hoc start ratio
|
||||
|
||||
핵심 질문:
|
||||
|
||||
- 이번 주에 시작 자체를 자주 했는가?
|
||||
- 특정 요일/시간대에서 시작이 잘 되었는가?
|
||||
|
||||
추천 카드:
|
||||
|
||||
- `시작 횟수`
|
||||
- `가장 잘 시작된 시간대`
|
||||
- `첫 세션 평균 시작 시각`
|
||||
|
||||
### Section C. Recovery Quality
|
||||
|
||||
목적:
|
||||
|
||||
- pause / away 이후 얼마나 다시 올라탔는지 보여준다
|
||||
|
||||
노출 지표:
|
||||
|
||||
- paused sessions
|
||||
- returned after pause
|
||||
- returned after away
|
||||
- recovery rate
|
||||
- avg time to recovery
|
||||
|
||||
핵심 질문:
|
||||
|
||||
- 흔들린 뒤에도 다시 돌아왔는가?
|
||||
- pause와 away 중 어디서 더 많이 끊겼는가?
|
||||
|
||||
추천 카드:
|
||||
|
||||
- `복귀율`
|
||||
- `pause 뒤 복귀`
|
||||
- `자리 비움 뒤 복귀`
|
||||
|
||||
### Section D. Completion Quality
|
||||
|
||||
목적:
|
||||
|
||||
- 끝까지 간 세션과 goal closure를 보여준다
|
||||
|
||||
노출 지표:
|
||||
|
||||
- completed sessions
|
||||
- completed goals
|
||||
- break started after completion
|
||||
- carry-over count
|
||||
|
||||
핵심 질문:
|
||||
|
||||
- 마무리까지 가는 흐름이 있었는가?
|
||||
- 목표를 닫지 못하고 다음 날로 밀린 경우가 많았는가?
|
||||
|
||||
추천 카드:
|
||||
|
||||
- `완료한 블록`
|
||||
- `끝까지 간 세션 비율`
|
||||
- `이월된 블록`
|
||||
|
||||
### Section E. Ritual Fit
|
||||
|
||||
목적:
|
||||
|
||||
- 어떤 scene / sound / timer 조합이 더 잘 맞는지 보여준다
|
||||
|
||||
노출 원칙:
|
||||
|
||||
- Free는 단일 best-fit hint 1개만
|
||||
- Pro는 ritual 비교까지 허용
|
||||
|
||||
예시:
|
||||
|
||||
- `Forest Draft · 50분에서 가장 오래 이어졌어요.`
|
||||
- `Rain 계열에서는 pause 후 복귀가 더 높았어요.`
|
||||
|
||||
중요:
|
||||
|
||||
- 이것도 “추천”이지 “정답 판정”이 아니다
|
||||
- 적은 샘플 수에서는 확정적 문장 금지
|
||||
|
||||
### Section F. Next Week Carry Forward
|
||||
|
||||
목적:
|
||||
|
||||
- review를 다음 세션 행동으로 연결
|
||||
|
||||
구성:
|
||||
|
||||
- `다음 주에 유지할 것`
|
||||
- `다음 주에 바꿔볼 것`
|
||||
- CTA: `이 ritual로 다음 세션 시작`
|
||||
|
||||
이 섹션이 없으면 review는 예쁜 요약으로 끝난다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 핵심 지표 정의
|
||||
|
||||
### Base Metrics
|
||||
|
||||
- `startedSessions`
|
||||
- `completedSessions`
|
||||
- `completedGoals`
|
||||
- `pausedSessions`
|
||||
- `returnedAfterPause`
|
||||
- `awayCandidates`
|
||||
- `returnedAfterAway`
|
||||
- `focusMinutes`
|
||||
- `breakSessions`
|
||||
- `carriedOverCount`
|
||||
|
||||
### Derived Metrics
|
||||
|
||||
- `startSuccessRate`
|
||||
- started days / active days
|
||||
- `completionRate`
|
||||
- completed sessions / started sessions
|
||||
- `pauseRecoveryRate`
|
||||
- returned after pause / paused sessions
|
||||
- `awayRecoveryRate`
|
||||
- returned after away / away candidates
|
||||
- `carryOverRate`
|
||||
- carried over goals / created goals
|
||||
- `ritualFitScore`
|
||||
- completion + recovery + avg session depth를 조합한 비교용 내부 점수
|
||||
|
||||
중요:
|
||||
|
||||
- `ritualFitScore`는 내부 계산용이다
|
||||
- 사용자에게 raw score를 그대로 보여주지 않는다
|
||||
- 사용자에게는 `이번 주 가장 잘 맞은 조합`처럼 문장으로만 보여준다
|
||||
|
||||
---
|
||||
|
||||
## 9. 허용되는 해석과 금지되는 해석
|
||||
|
||||
### 허용
|
||||
|
||||
- `최근 7일 중 오전 시작 세션의 완료율이 가장 높았어요.`
|
||||
- `25분보다 50분 세션에서 복귀율이 높았어요.`
|
||||
- `Forest + Birds에서 pause 뒤 복귀가 가장 높았어요.`
|
||||
|
||||
### 금지
|
||||
|
||||
- `당신은 밤형 인간이에요.`
|
||||
- `의지가 부족했어요.`
|
||||
- `이번 주 생산성이 낮았어요.`
|
||||
- `당신은 긴 세션에 맞지 않아요.`
|
||||
|
||||
원칙:
|
||||
|
||||
- 행동 데이터 -> 짧은 문장
|
||||
- 심리 진단 -> 금지
|
||||
|
||||
---
|
||||
|
||||
## 10. UI/UX 방향
|
||||
|
||||
### 전체 톤
|
||||
|
||||
- stats dashboard가 아니라 calm review desk
|
||||
- 라이트 팔레트는 유지하되, 문서성과 감정적 안정감이 있어야 한다
|
||||
- 차트는 줄이고, 문장과 비교를 늘린다
|
||||
|
||||
### 시각 원칙
|
||||
|
||||
- 큰 숫자 카드 남발 금지
|
||||
- 한 줄 summary와 2~3개 핵심 비교를 우선
|
||||
- “이번 주 / 시작 / 복귀 / 완료 / 다음 주” 순서로 읽히게 한다
|
||||
- 회고가 무거운 분석처럼 보이면 안 된다
|
||||
|
||||
### 좋은 흐름
|
||||
|
||||
1. 이번 주 한 줄 요약
|
||||
2. 시작은 어땠나
|
||||
3. 흔들린 뒤 돌아왔나
|
||||
4. 끝까지 갔나
|
||||
5. 다음 주엔 뭘 유지/변경할까
|
||||
|
||||
### 나쁜 흐름
|
||||
|
||||
1. 카드 12개
|
||||
2. 차트 4개
|
||||
3. 해석 7개
|
||||
4. 추천 9개
|
||||
|
||||
이건 LifeAt식 dashboard로 읽히고, VibeRoom과 맞지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 11. Free / Pro 재정의
|
||||
|
||||
### Free Weekly Review
|
||||
|
||||
Free는 다음만 보여준다.
|
||||
|
||||
- 이번 주 snapshot 1개
|
||||
- Start Quality 핵심 카드 1~2개
|
||||
- Recovery Quality 핵심 카드 1개
|
||||
- Completion Quality 핵심 카드 1개
|
||||
- Next Week Carry Forward 한 줄 제안 1개
|
||||
|
||||
Free에서 하지 않는 것:
|
||||
|
||||
- multi-week trend
|
||||
- ritual 비교표
|
||||
- atmosphere/duration 조합 비교
|
||||
- archive 기반 long-term pattern
|
||||
|
||||
Free 가치:
|
||||
|
||||
- `내가 이번 주에 어떻게 집중했는지`를 짧게 이해
|
||||
- 다음 세션에 바로 적용할 한 줄 얻기
|
||||
|
||||
### Pro Weekly Review
|
||||
|
||||
Pro는 아래까지 연다.
|
||||
|
||||
- 4주 trend
|
||||
- ritual fit comparison
|
||||
- start window pattern
|
||||
- recovery pattern split
|
||||
- pause recovery
|
||||
- away recovery
|
||||
- best ritual recommendation
|
||||
- carry-forward note history
|
||||
- direct CTA
|
||||
- `이 ritual로 다음 세션 시작`
|
||||
- `이번 주 가장 잘 맞은 조합으로 시작`
|
||||
|
||||
Pro 가치:
|
||||
|
||||
- 더 깊은 분석이 아니라
|
||||
- `내가 어떤 조건에서 덜 무너지는지`를 실제로 알게 한다
|
||||
|
||||
### BM 원칙
|
||||
|
||||
Review는 Pro에서 이렇게 팔아야 한다.
|
||||
|
||||
- “고급 통계”
|
||||
- “생산성 분석”
|
||||
|
||||
가 아니라
|
||||
|
||||
- `내 집중 리듬을 더 잘 이해하게 해주는 weekly review`
|
||||
- `다음 주의 시작 성공률을 높여주는 personalized reflection`
|
||||
|
||||
---
|
||||
|
||||
## 12. 사용자 플로우
|
||||
|
||||
### Flow A. 주간 진입
|
||||
|
||||
trigger:
|
||||
|
||||
- `/stats` 직접 진입
|
||||
- `/app` teaser 클릭
|
||||
- 주말/주간 종료 시점의 passive prompt
|
||||
|
||||
flow:
|
||||
|
||||
1. snapshot hero
|
||||
2. start / recovery / completion
|
||||
3. ritual fit
|
||||
4. next week carry forward
|
||||
5. next CTA
|
||||
|
||||
### Flow B. 세션 직후 light review teaser
|
||||
|
||||
trigger:
|
||||
|
||||
- 금주 4번째 completed session
|
||||
- 또는 금주 1번째 return after pause
|
||||
|
||||
teaser 예시:
|
||||
|
||||
- `이번 주 pause 뒤 복귀가 3번 있었어요`
|
||||
- `Forest Draft 50분에서 가장 잘 이어졌어요`
|
||||
|
||||
CTA:
|
||||
|
||||
- `주간 review 보기`
|
||||
|
||||
### Flow C. 다음 세션 연결
|
||||
|
||||
weekly review 마지막 CTA:
|
||||
|
||||
- `이 조합으로 다음 세션 시작`
|
||||
- `/app`으로 연결하되 atmosphere와 duration을 prefill
|
||||
|
||||
즉 review는 읽고 끝나는 화면이 아니라 다음 entry에 연결돼야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 13. 정보 설계와 컴포넌트 구조 제안
|
||||
|
||||
### `/stats` 1-screen IA
|
||||
|
||||
1. Header
|
||||
2. Weekly Snapshot hero
|
||||
3. Start Quality
|
||||
4. Recovery Quality
|
||||
5. Completion Quality
|
||||
6. Ritual Fit
|
||||
7. Next Week Carry Forward
|
||||
|
||||
### 프론트 컴포넌트 제안
|
||||
|
||||
- `WeeklyReviewHero`
|
||||
- `ReviewMetricPair`
|
||||
- `RecoverySplitCard`
|
||||
- `RitualFitHighlight`
|
||||
- `CarryForwardPanel`
|
||||
|
||||
현재의 작은 stat card 반복을 그대로 늘리는 방향은 피한다.
|
||||
|
||||
---
|
||||
|
||||
## 14. API / 데이터 계약 제안
|
||||
|
||||
### 새 응답 모델
|
||||
|
||||
`GET /api/v1/stats/weekly-review`
|
||||
|
||||
response shape:
|
||||
|
||||
- `weekRange`
|
||||
- `snapshot`
|
||||
- `startQuality`
|
||||
- `recoveryQuality`
|
||||
- `completionQuality`
|
||||
- `ritualFit`
|
||||
- `carryForward`
|
||||
- `source`
|
||||
|
||||
### snapshot
|
||||
|
||||
- `startedSessions`
|
||||
- `returnedSessions`
|
||||
- `completedGoals`
|
||||
- `focusMinutes`
|
||||
- `summaryLine`
|
||||
|
||||
### startQuality
|
||||
|
||||
- `startedSessions`
|
||||
- `activeDays`
|
||||
- `bestStartWindow`
|
||||
- `adHocVsPlanned`
|
||||
|
||||
### recoveryQuality
|
||||
|
||||
- `pausedSessions`
|
||||
- `returnedAfterPause`
|
||||
- `awayCandidates`
|
||||
- `returnedAfterAway`
|
||||
- `recoveryRate`
|
||||
|
||||
### completionQuality
|
||||
|
||||
- `completedSessions`
|
||||
- `completedGoals`
|
||||
- `carriedOverCount`
|
||||
- `completionRate`
|
||||
|
||||
### ritualFit
|
||||
|
||||
- `topScene`
|
||||
- `topSound`
|
||||
- `topTimerPreset`
|
||||
- `topComboSummary`
|
||||
- `confidence`
|
||||
|
||||
### carryForward
|
||||
|
||||
- `keepDoing`
|
||||
- `tryNext`
|
||||
- `recommendedEntryPreset`
|
||||
|
||||
### write endpoint
|
||||
|
||||
선택 기능:
|
||||
|
||||
`POST /api/v1/stats/weekly-review/carry-forward`
|
||||
|
||||
용도:
|
||||
|
||||
- 다음 주 유지/변경 메모 저장
|
||||
|
||||
초기 v1에서는 read-only로 시작해도 된다.
|
||||
|
||||
---
|
||||
|
||||
## 15. 구현 순서
|
||||
|
||||
### Slice 1. 데이터 모델 재정의
|
||||
|
||||
- weekly-review 응답 계약 설계
|
||||
- mock + API 양쪽 shape 통일
|
||||
|
||||
### Slice 2. `/stats` IA 재구성
|
||||
|
||||
- factual summary card 반복을 해체
|
||||
- hero + 4 quality sections + carry-forward로 재배치
|
||||
|
||||
### Slice 3. copy / interpretation system
|
||||
|
||||
- 허용되는 summary line 규칙 정리
|
||||
- 과한 해석 제거
|
||||
|
||||
### Slice 4. Free / Pro gating
|
||||
|
||||
- Free는 7일 snapshot 중심
|
||||
- Pro는 4주 trend / ritual fit 확장
|
||||
|
||||
### Slice 5. `/app` 연결
|
||||
|
||||
- review teaser
|
||||
- next-session start CTA 연결
|
||||
|
||||
---
|
||||
|
||||
## 16. 완료 기준
|
||||
|
||||
- `/stats`가 더 이상 generic dashboard처럼 보이지 않는다
|
||||
- 사용자가 review를 보고 `다음 주엔 이걸 바꿔봐야겠다`를 얻는다
|
||||
- review가 다음 세션 entry와 연결된다
|
||||
- Free에서도 충분한 가치가 있고, Pro는 확실히 더 깊다
|
||||
- Pro 가치가 `더 많은 분석`이 아니라 `더 잘 시작하고 덜 무너지는 자기 이해`로 읽힌다
|
||||
|
||||
---
|
||||
|
||||
## 17. 절대 피해야 할 방향
|
||||
|
||||
- planner analytics로 확장
|
||||
- task completion dashboard화
|
||||
- 생산성 점수화
|
||||
- 심리 진단처럼 보이는 카피
|
||||
- 차트와 카드 수를 premium으로 오해하는 것
|
||||
- review를 `/space`보다 더 시끄러운 화면으로 만드는 것
|
||||
@@ -1,6 +1,6 @@
|
||||
# Session Brief
|
||||
|
||||
Last Updated: 2026-03-05
|
||||
Last Updated: 2026-03-16
|
||||
|
||||
세션 시작 시 항상 읽는 초소형 스냅샷 문서.
|
||||
|
||||
@@ -14,23 +14,168 @@ Last Updated: 2026-03-05
|
||||
|
||||
## 현재 우선순위
|
||||
|
||||
1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 마감 품질 점검
|
||||
2. Notes(쓰기) / Inbox(읽기·정리) 복귀 동선과 30초 숨고르기 카피 정리
|
||||
3. Stage 가독성/모션/레이어 폴리시 최종 정리
|
||||
1. `/space` completion result modal browser QA
|
||||
2. `/space` current-session-only cleanup
|
||||
3. `Core Loop Alignment` browser audit
|
||||
4. `Weekly Review` carry-forward 고도화
|
||||
|
||||
## 최근 세션 상태
|
||||
|
||||
- `/app` 전면 재설계 방향을 새 spec으로 고정했다.
|
||||
- no-session `/app`은 `goal + duration + atmosphere` 중심의 premium entry stage로 바뀐다.
|
||||
- microStep은 `/app`에서 제거하고, duration은 분 단위 직접 입력으로 전환한다.
|
||||
- scene + sound 조합 카드는 `Atmosphere`로 부르며, 첫 구현은 12개 dummy grid를 사용한다.
|
||||
- weekly review와 achieved-goal insight는 main stage가 아니라 quiet secondary dock로 유지한다.
|
||||
- `/app` Atmosphere Entry Shell 1차 구현을 반영했다.
|
||||
- no-session 상태는 더 이상 legacy `goal + microStep + fixed ritual` 화면을 쓰지 않는다.
|
||||
- 현재는 `goal 1개 + 예상 시간(분) + atmosphere 12개 grid + start CTA`로 들어간다.
|
||||
- 선택한 atmosphere는 `/app` 배경 preview와 `/space` start payload의 `scene/sound`에 같이 반영된다.
|
||||
- duration은 raw `focusDurationMinutes`로 server에 전달한다.
|
||||
- weekly review entry는 right-side quiet dock 위치로 옮겨 main CTA보다 낮은 위계를 유지한다.
|
||||
- `/app` Atmosphere Entry Shell visual premium polish를 반영했다.
|
||||
- utility card 묶음 대신 `decision rail + selected atmosphere stage + curated library` 구조로 재구성했다.
|
||||
- 좌측은 입력과 결심, 우측은 immersive preview, 하단은 12개 atmosphere library로 위계를 고정했다.
|
||||
- review entry는 start stage 아래의 quiet dock로 낮춰 main CTA와 경쟁하지 않게 정리했다.
|
||||
- `/app`은 이제 session gate를 보여주지 않는다.
|
||||
- current session이 있으면 상태와 상관없이 즉시 `/space`로 이동한다.
|
||||
- 따라서 paused resume gate와 takeover sheet는 current UX가 아니다.
|
||||
- server `startSession()`은 여전히 current session 존재 시 direct start를 거절해 silent abandon을 막는다.
|
||||
- `/space` Refocus System 첫 slice를 구현했다.
|
||||
- pause 직후 바로 편집 시트가 아니라 작은 recovery prompt를 먼저 띄운다.
|
||||
- 여기서 `한 조각 다시 잡기`를 누르면 refocus tray로 들어간다.
|
||||
- paused 상태의 refocus는 `적용하고 이어가기`로 바로 resume까지 연결된다.
|
||||
- microStep 완료 후에는 checklist가 아니라 `다음 한 조각이 있나요?` prompt로만 이어진다.
|
||||
- recovery UI는 `paused / refocus / next-beat / complete` 중 하나만 열리도록 단일 overlay 상태로 묶였다.
|
||||
- `/space` goal complete 종료 경로를 복구했다.
|
||||
- `여기서 마무리하기`로 현재 목표를 다음 목표 입력 없이도 정상 완료 처리할 수 있다.
|
||||
- pause prompt의 `이대로 이어가기`는 단순 닫기가 아니라 실제 resume으로 연결된다.
|
||||
- `/space` goal complete / next beat를 덜 form스럽게 정리했다.
|
||||
- goal complete는 처음부터 input을 요구하지 않고, 선택지를 먼저 보여준 뒤 `다음 목표 이어가기`를 선택했을 때만 입력이 열린다.
|
||||
- next-beat prompt는 현재 goal 문맥을 함께 보여줘서 사용자가 어떤 목표를 이어가는지 잃지 않게 했다.
|
||||
- `/space` recovery tray material과 선택 위계를 같은 패밀리로 맞추기 시작했다.
|
||||
- pause / next-beat / complete tray가 공통 dark-glass shell을 공유한다.
|
||||
- inline 링크 중심이던 선택지를 quiet option row 구조로 바꿔, checklist보다 recovery decision처럼 읽히게 정리했다.
|
||||
- `Goal Complete`는 `다음 블록 이어가기 / 잠시 비우기 / 여기서 마무리하기`를 같은 tray 안의 선택 행으로 제시한다.
|
||||
- `Refocus`는 같은 shell 안에서 field / action 톤을 통일해 다른 tray와 같은 제품군처럼 보이게 맞추는 중이다.
|
||||
- `/space` Away / Return Recovery를 구현했다.
|
||||
- `visibilitychange`, `pagehide`, sleep/wake gap 기반 감지를 추가했다.
|
||||
- 짧은 탭 전환에는 반응하지 않도록 hidden threshold를 둬 오탐을 줄였다.
|
||||
- 돌아왔을 때 focus가 계속 running이면 `Return` tray가 `이어서 하기 / 한 조각 다시 잡기`를 제안한다.
|
||||
- 자리를 비운 사이 focus가 끝나 break phase가 되었으면 standard break 대신 `Return` tray가 먼저 뜬다.
|
||||
- 이 경우 `쉬기 이어가기 / 다음 목표 이어가기 / 한 조각 다시 잡기` 중 하나를 고를 수 있다.
|
||||
- `다음 목표 이어가기`는 goal complete next view로 바로 연결된다.
|
||||
- pause tray의 visual polish를 진행했다.
|
||||
- tray 폭과 max-height를 늘려 한국어 제목/설명 잘림을 줄였다.
|
||||
- title/body line-height와 spacing을 다시 잡아 임시 패널 느낌을 줄였다.
|
||||
- option row의 radius, padding, chevron 정렬을 보정해 더 차분한 recovery panel처럼 읽히게 했다.
|
||||
- `Pause / Break / Return`의 감정 톤 분리를 시작했다.
|
||||
- `Return(break)`은 focus 복귀 tray와 같은 재질을 쓰지 않고, 더 부드러운 emerald tint release tone으로 분리했다.
|
||||
- `Goal Complete`의 `잠시 비우기` 선택도 같은 release tone으로 연결했다.
|
||||
- timer HUD도 break phase에서는 더 가벼운 emerald 계열 material로 바뀌어 pause/focus와 다르게 읽히도록 정리 중이다.
|
||||
- `Pause / Break / Return`의 카피와 CTA 위계를 2차로 분리했다.
|
||||
- `Pause`는 `멈춘 이유`보다 `다시 시작할 한 줄`에 집중하는 recovery tone으로 다시 썼다.
|
||||
- `Return(focus)`는 `이어가기`, `Return(break)`는 `쉬기 이어가기 / 다음 블록 이어가기` 중심으로 문구를 분리했다.
|
||||
- `Goal Complete`는 `이어가기 / 잠시 비우기 / 마무리하기`의 선택 tray가 먼저 보이고, 다음 블록 입력은 이후 단계에서만 열리도록 더 선명해졌다.
|
||||
- `Pause / Break / Return`의 motion polish 1차를 반영했다.
|
||||
- `Pause`는 빠르게 다시 붙잡는 recovery reveal로,
|
||||
- `Return(focus)`는 재진입에 맞는 짧은 settle motion으로,
|
||||
- `Return(break)`와 `Goal Complete`는 더 느슨한 release/closure reveal로 분리했다.
|
||||
- `/space` active session에서는 goal closure 경로가 항상 남도록 정리했다.
|
||||
- `break`에서도 expanded goal card 안에 `이번 목표 완료`가 보인다.
|
||||
- `pause / return / next-beat`처럼 base card가 잠기는 overlay 안에는 low-emphasis `여기서 마무리하기`가 추가됐다.
|
||||
- focus timer가 끝나면 더 이상 break로 자동 반복되지 않는다.
|
||||
- `00:00`이 되면 `완료하고 종료하기 / 10분 더` 모달이 자동으로 열린다.
|
||||
- `10분 더`는 server `extend-phase` 계약을 타고 현재 focus phase를 10분 연장한 뒤 다시 running으로 이어진다.
|
||||
- 그래서 사용자는 recovery 상태에서도 `계속 / 다시 잡기 / 마무리` 중 하나를 바로 고를 수 있다.
|
||||
- `/space` 종료 결과 모달이 추가됐다.
|
||||
- `완료하고 종료하기`와 `여기서 마무리하기`는 바로 `/app`으로 가지 않고 중앙 결과 모달을 먼저 띄운다.
|
||||
- 결과 모달에는 `집중한 시간`, `완료한 목표`, `이번 세션 thought capsule`이 들어간다.
|
||||
- 닫기 전까지 `/space`는 결과 모달 상태로 유지되고, `확인하고 돌아가기`에서만 `/app`으로 이동한다.
|
||||
- thought capsule은 서버가 현재 세션에 내부 귀속한다.
|
||||
- 클라이언트는 `focusSessionId`를 보내지 않는다.
|
||||
- `/space`는 current session이 살아 있으면 server `current thoughts` API로 same-session thought 목록을 복원한다.
|
||||
- 그래서 브라우저 재시작 후에도 같은 세션이라면 결과 모달에 같은 thought들이 포함된다.
|
||||
- `/space` 목표 카드를 collapsed / expanded 구조로 재설계했다.
|
||||
- idle에서는 goal 1줄만 남는 얇은 glass rail로 줄였다.
|
||||
- microStep과 `이번 목표 완료`는 expanded 상태에서만 드러난다.
|
||||
- recovery tray가 열리면 base card는 자동으로 collapsed 상태를 유지한다.
|
||||
- expanded rail은 outside click으로 접히지만, decision tray는 outside click으로 닫히지 않는다.
|
||||
- rail 클릭은 펼침/접힘만 담당하고, 수정은 expanded 상태의 `수정` 액션으로만 진입한다.
|
||||
- `/app`은 legacy single-goal gate에서 `goal + duration + atmosphere` 중심의 새 entry shell로 전환 중이다.
|
||||
- current session이 있으면 `Resume` gate가 우선 노출된다.
|
||||
- no-session 상태는 `goal 1개 + 예상 시간 + 선택된 atmosphere + 시작 CTA` 구조로 재설계 중이다.
|
||||
- `microStep`은 `/app`에서 제거하고 `/space` recovery/refocus에서만 다시 잡는다.
|
||||
- weekly review는 hero 아래 카드가 아니라 quiet secondary dock/entry로 유지한다.
|
||||
- manage/list 성격의 affordance는 메인 진입 경로에서 계속 배제한다.
|
||||
- `/space`는 execution-only surface로 정리됐다.
|
||||
- setup drawer에서 Daily Plan / Ritual Library 섹션을 제거했다.
|
||||
- goal, scene, sound, timer만 확인하고 focus HUD로 진입한다.
|
||||
- 목표 완료 후 다음 목표 즉시 실행 흐름이 backend contract와 연결됐다.
|
||||
- GoalCompleteSheet confirm 시 `advance-goal` endpoint를 사용한다.
|
||||
- 현재 세션 완료, linked plan item 완료, 새 current item 생성, 다음 세션 시작을 한 번에 처리한다.
|
||||
- 실패 시 시트를 닫지 않고 그대로 재시도할 수 있다.
|
||||
- `/stats`는 factual summary에서 `Weekly Review` 1차 구조로 올라갔다.
|
||||
- hero snapshot, start quality, recovery quality, completion quality, carry forward 구조를 사용한다.
|
||||
- 기존 `focus-summary` 응답은 review view model로 변환해서 쓴다.
|
||||
- recovery는 서버의 `pause 뒤 복귀` 집계를 사용하고, `away recovery`만 limited state로 남긴다.
|
||||
- `/stats` immersive review stage polish를 반영했다.
|
||||
- dark atmosphere 배경 위에 중앙 hero summary, snapshot signal board, review panel, carry-forward closure stage를 같은 glass family로 재구성했다.
|
||||
- `/app`의 premium immersive tone과 같은 제품군은 유지하되, 입력 ritual을 닮지 않는 stats 고유의 observatory 톤으로 분리했다.
|
||||
- `/app`에서 `/stats`로 들어가는 primary path 1차가 생겼다.
|
||||
- current session이 없을 때는 quiet review dock에서 `/stats`로 진입할 수 있다.
|
||||
- review entry는 main start CTA보다 항상 낮은 강조를 유지한다.
|
||||
- `/stats` 마지막 CTA의 `/app` return handoff가 연결됐다.
|
||||
- carry-forward CTA는 `/app?review=weekly&carryHint=...`로 돌아온다.
|
||||
- `/app`은 review-aware return hint를 먼저 보여주되, goal은 사용자가 직접 입력하게 유지한다.
|
||||
- `Weekly Review Entry Flow`의 Pro personalized handoff까지 연결됐다.
|
||||
- Pro에서는 `/stats` carry-forward에 추천 ritual을 함께 보여준다.
|
||||
- `/stats` 마지막 CTA와 `/app` teaser / return hint가 더 구체적인 handoff 톤으로 바뀐다.
|
||||
- `Weekly Review` recovery의 서버 연결이 들어갔다.
|
||||
- server `focus-summary` 응답에 `recovery`가 추가됐다.
|
||||
- 현재는 `pause 뒤 복귀`만 실집계이며, `자리 비움 뒤 복귀`는 partial note로 남아 있다.
|
||||
- session routing 정책을 다시 단순화했다.
|
||||
- current session이 있으면 `/app`에서 머무르지 않고 바로 `/space`로 이동한다.
|
||||
- `/space`는 paused session이라고 `/app`으로 되돌리지 않는다.
|
||||
- `/app`은 no-session entry surface로만 남는다.
|
||||
- `Product Alignment Audit` 운영을 시작했다.
|
||||
- `docs/product/16_product_alignment_audit_plan.md`를 기준 문서로 추가했다.
|
||||
- `docs/product/17_product_alignment_findings.md`에 core loop의 P1/P2 mismatch를 수집하기 시작했다.
|
||||
- 다음 라운드는 route-flow matrix와 state contract matrix를 만드는 static audit이다.
|
||||
- 유료화 포지셔닝을 `Calm Session OS`로 재정의했다.
|
||||
- Free는 기본 집중 시작, Pro는 더 잘 이어가기라는 메시지로 정리했다.
|
||||
- old `Scene Packs / Sound Packs / Profiles` 중심 카피를 `Daily plan / Rituals / Weekly review` 구조로 교체했다.
|
||||
- `/app`은 더 이상 `/space` redirect가 아니다.
|
||||
- `FocusDashboardWidget`에서 goal only start와 plan-linked start를 모두 처리한다.
|
||||
- Free에서 두 번째 블록 추가 시도는 manage sheet 안에서 paywall로 진입한다.
|
||||
- 플랜 tier를 route 간에 공유하도록 정리했다.
|
||||
- `usePlanTier` localStorage store를 추가해 `/app`, `/space`, `/stats`가 같은 Free/Pro 상태를 본다.
|
||||
- Session OS mock 도메인을 추가했다.
|
||||
- `FocusPlanItem`, `SessionTemplate`, `SessionOutcome`, `WeeklyReview`, `AsyncCheckIn` 모델과 mock 데이터를 `entities/session`에 추가했다.
|
||||
- `/space` stage 배경을 overscan으로 보정했다.
|
||||
- background layer를 `-inset-8`로 확장해 pan 애니메이션 중 가장자리 빈틈 노출을 줄였다.
|
||||
- Gemini가 진행한 대형 파일 분리를 다시 점검했다.
|
||||
- `SpaceWorkspaceWidget`, `SpaceToolsDockWidget`, `admin/page.tsx`, `shared/i18n/ko.ts` 모두 500줄 기준 안으로 정리된 상태를 재확인했다.
|
||||
- 현재 주요 최대 파일은 `AdminDashboardView.tsx` 482줄, `useSpaceWorkspaceSelection.ts` 440줄 수준이다.
|
||||
- `/admin` 분리 과정의 placeholder 회귀를 복구했다.
|
||||
- 로그인 후 `Dashboard is under construction`만 보이던 상태를 제거했다.
|
||||
- 실제 scene/sound 업로드 폼을 `AdminDashboardView`로 복원해 `AdminConsoleWidget`은 조합만 담당하도록 되돌렸다.
|
||||
- 인증 전역 저장소 위치를 정리했다.
|
||||
- `src/store/useAuthStore.ts`를 제거했다.
|
||||
- 인증 상태 저장소를 `entities/auth/model/useAuthStore.ts`로 이동해 feature가 루트 store를 직접 참조하지 않도록 정리했다.
|
||||
- `/space` 배경 asset 해석을 보강했다.
|
||||
- media manifest scene key를 alias-aware 하게 정규화해 `green-forest`와 `forest`를 같은 scene asset으로 읽는다.
|
||||
- scene/sound asset에 `source(fallback|remote)` 메타를 추가해 remote asset 사용 여부를 코드에서 바로 식별할 수 있다.
|
||||
- remote manifest load 실패와 scene fallback 사용 시 `/space`에서 진단 로그를 남기도록 보강했다.
|
||||
- Focus 피드백 채널을 상단 중앙 1곳으로 통합했다.
|
||||
- HUD 내부 status line 제거
|
||||
- Notes/Goal/잠금 피드백이 동일 위치 토스트로 표시
|
||||
- 기본 기능 잠금을 해소했다.
|
||||
- Time `90/20`을 Free로 개방
|
||||
- 기본 Sound 잠금 제거
|
||||
- Pro 잠금 구조를 Packs/Profiles 중심으로 재구성했다.
|
||||
- `Scene Packs / Sound Packs / Profiles` 요약 카드 추가
|
||||
- Pro 잠금 구조를 Session OS 중심으로 재구성했다.
|
||||
- `Daily Focus Plan / Rituals / Weekly Review` 요약 카드 추가
|
||||
- 기본 Scene/Time/Sound는 잠금 없이 선택 가능
|
||||
- Paywall 시트는 잠금 카드 클릭에서만 열리도록 바꿨다.
|
||||
- Plan Pill(NORMAL) 클릭은 즉시 결제창 오픈 대신 상태 안내만 노출
|
||||
- Plan Pill(FREE) 클릭은 즉시 결제창 오픈 대신 상태 안내만 노출
|
||||
- Paywall 카피를 3개 가치 포인트 + 2개 CTA로 간결화
|
||||
- Focus-First 구조로 전환했다.
|
||||
- Quick Controls의 모드 전환 토글(기본/몰입)을 제거했다.
|
||||
@@ -40,23 +185,23 @@ Last Updated: 2026-03-05
|
||||
- 옵션: `컨트롤 자동 숨김`
|
||||
- ON 상태에서 Control Center가 8초 무입력이면 자동 닫힘 처리
|
||||
- `/space`에 Scene 추천 자동 적용 규칙을 도입했다.
|
||||
- Room 데이터에 `recommendedSoundPresetId`, `recommendedTimerPresetId`를 추가했다.
|
||||
- Room 데이터에 `recommendedSoundPresetId`를 추가했다.
|
||||
- 초기 진입/Scene 변경 시 override가 없는 항목만 추천값으로 자동 반영된다.
|
||||
- `/space`에 override 상태(`sound`, `timer`)를 추가했다.
|
||||
- 사용자가 직접 고른 사운드/타이머는 Scene 변경에도 자동 덮어쓰지 않는다.
|
||||
- `/space`에 override 상태(`sound`, `duration`)를 추가했다.
|
||||
- 사용자가 직접 고른 사운드/duration은 Scene 변경에도 자동 덮어쓰지 않는다.
|
||||
- `추천으로 되돌리기(더미)` 액션으로 override 초기화 + 추천값 즉시 복원이 가능하다.
|
||||
- Control Center를 Scene/Time 중심으로 단순화했다.
|
||||
- Sound/Preset Packs 섹션 제거
|
||||
- 추천 정보 1줄 + `추천으로 되돌리기`만 유지
|
||||
- 우하단 Sound Quick 선택 경로를 `onQuickSoundSelect`로 분리해 override.sound 규칙을 명시했다.
|
||||
- `/space` 선택 상태 로컬 저장/복원을 추가했다.
|
||||
- 저장: `sceneId`, `timerPresetId`, `soundPresetId`, `goal`, `override(sound/timer)`
|
||||
- 저장: `sceneId`, `durationMinutes`, `soundPresetId`, `goal`, `override(sound/duration)`
|
||||
- 복원 우선순위: 쿼리 파라미터 > 저장 상태 > Scene 추천
|
||||
- `/space` 진입 시 Resume CTA를 추가했다.
|
||||
- 저장된 목표가 있고 쿼리 오버라이드가 없으면 `지난 한 조각 이어서`를 1회 노출
|
||||
- `이어서 시작`은 즉시 Focus 진입, `새로 시작`은 목표를 비운 새 세션으로 전환
|
||||
- 세션 복구용 문서/템플릿/스크립트가 준비되어 있다.
|
||||
- `workFlow.md`는 토큰 절약 모드를 사용한다.
|
||||
- `ops/workFlow.md`는 토큰 절약 모드를 사용한다.
|
||||
- `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다.
|
||||
- `/space` 헤더 프레임을 축소하고 HUD를 하단 safe-area 기준으로 더 밀착시켰다.
|
||||
- 상단 우측 나가기 액션을 클릭형에서 1초 롱프레스형으로 전환했다.
|
||||
@@ -114,6 +259,14 @@ Last Updated: 2026-03-05
|
||||
|
||||
## 리스크
|
||||
|
||||
- Session OS 데이터는 아직 mock 기반이므로 실제 저장/동기화 API 없이도 화면이 그럴듯하게만 보일 수 있다.
|
||||
- empty state에서 CTA는 유지하지만 실제 시작 전에 입력 포커스가 먼저 필요하므로, 첫 진입 사용성은 브라우저 확인이 필요하다.
|
||||
- current item이 아닌 preview row 선택은 ad-hoc start로 처리되므로, 큐 재정렬을 기대하는 사용자와 정신 모델 차이가 날 수 있다.
|
||||
- `/space` paywall 전환 진입점은 `/app` / `/stats` 중심이라 execution 화면만 본 사용자에게는 업그레이드 맥락이 약할 수 있다.
|
||||
- stage background overscan으로 좁은 화면에서 배경 crop 체감이 조금 더 커질 수 있다.
|
||||
- remote manifest 실패 시 원인 진단은 가능하지만, 사용자용 복구 CTA는 아직 없다.
|
||||
- `/admin` 업로드 콘솔은 타입/구조상 복구됐지만 실제 브라우저 업로드 스모크는 아직 필요하다.
|
||||
- alias 목록에 없는 legacy scene id가 다시 들어오면 scene fallback 문제가 재발할 수 있다.
|
||||
- 네트워크 제한 환경에서는 `npm run build` 시 Google Fonts fetch 실패 가능
|
||||
- localStorage 저장 포맷 변경 시 이전 세션 데이터와의 호환성 이슈가 생길 수 있음
|
||||
- Scene 추천값이 사용자 선호와 어긋나면 자동 추천 체감 품질이 낮을 수 있음
|
||||
@@ -135,5 +288,5 @@ Last Updated: 2026-03-05
|
||||
## 상세 원문 위치
|
||||
|
||||
- 장문 상세 상태: `docs/90_current_state.md`
|
||||
- 구조 규칙 상세: `docs/02_arch_fsd_rules.md`
|
||||
- 커밋 규칙 상세: `docs/06_commit_convention.md`
|
||||
- 구조 규칙 상세: `docs/foundation/02_arch_fsd_rules.md`
|
||||
- 커밋 규칙 상세: `docs/foundation/06_commit_convention.md`
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
[Step 0: 컨텍스트 로드]
|
||||
아래 파일을 먼저 읽고, 핵심 규칙/현재 상태/다음 작업을 10줄 내로 요약해라.
|
||||
|
||||
- docs/00_project_brief.md
|
||||
- docs/01_ui_guidelines.md
|
||||
- docs/02_arch_fsd_rules.md
|
||||
- docs/03_routes_map.md
|
||||
- docs/04_coding_rules.md
|
||||
- docs/05_handoff_checklist.md
|
||||
- docs/06_commit_convention.md
|
||||
- docs/07_session_recovery.md
|
||||
- docs/foundation/00_project_brief.md
|
||||
- docs/foundation/01_ui_guidelines.md
|
||||
- docs/foundation/02_arch_fsd_rules.md
|
||||
- docs/foundation/03_routes_map.md
|
||||
- docs/foundation/04_coding_rules.md
|
||||
- docs/ops/05_handoff_checklist.md
|
||||
- docs/foundation/06_commit_convention.md
|
||||
- docs/ops/07_session_recovery.md
|
||||
- docs/90_current_state.md
|
||||
|
||||
[Step 1: 현재 상태 점검]
|
||||
@@ -30,5 +30,5 @@ docs/90_current_state.md의 “NEXT” 섹션에 적힌 우선순위 1번 작업
|
||||
|
||||
- docs/90_current_state.md를 업데이트해라:
|
||||
- DONE / NEXT / RISKS / CHANGED FILES
|
||||
- 필요하면 docs/01_ui_guidelines.md 또는 docs/02_arch_fsd_rules.md에 결정사항을 추가해라
|
||||
- 필요하면 docs/foundation/01_ui_guidelines.md 또는 docs/foundation/02_arch_fsd_rules.md에 결정사항을 추가해라
|
||||
- 변경사항이 합리적인 단위로 커밋될 수 있도록 커밋 메시지 초안을 제안해라
|
||||
|
||||
143
docs/work.md
143
docs/work.md
@@ -6,7 +6,8 @@
|
||||
|
||||
- 작업은 가능한 한 "주제별"로 분리해서 작성한다.
|
||||
- 한 주제는 가능하면 한 커밋으로 끝낼 수 있게 범위를 좁힌다.
|
||||
- "금지사항/제외 범위"를 명시해서 불필요한 변경을 막는다.
|
||||
- `finding -> fix -> docs -> validation -> commit`까지 한 라운드에서 닫는다.
|
||||
- browser QA가 필요한 작업은 반드시 완료 조건에 명시한다.
|
||||
|
||||
## 우선순위
|
||||
|
||||
@@ -17,111 +18,119 @@
|
||||
|
||||
## 작업 1
|
||||
|
||||
- 제목: 코어 루프 완성 — Goal Complete Sheet(다음 한 조각 입력) 마감
|
||||
- 제목: `/space` completion result modal browser QA
|
||||
- 목적:
|
||||
- 이 앱의 재방문/체감은 “완료 → 다음 목표”가 자연스럽게 이어질 때 생긴다.
|
||||
- Focus 화면에서 목표 완료가 폼 UI처럼 보이지 않도록 하고, 완료 후 다음 한 조각 입력 플로우를 프리미엄스럽게 만든다.
|
||||
- 세션 완전 종료 직후 결과 모달이 자연스럽게 뜨는지 확인한다.
|
||||
- timer-complete, End Session finish, 10분 더 이후 재종료, thought 복원 흐름을 실제 브라우저에서 검증한다.
|
||||
- 변경 범위:
|
||||
- Focus HUD의 목표는 “1줄 앵커”로 유지(상시 큰 카드 금지)
|
||||
- 완료 트리거(1개만 선택해 고정):
|
||||
- Goal 1줄 앵커 롱프레스(1초) 또는 작은 ghost ‘완료’(체크박스 금지)
|
||||
- 완료 시 Goal Complete Sheet 표시(하단 시트)
|
||||
- 타이틀 + 입력 1개 + 추천 칩 4개 + CTA 2개(바로 다음 조각 시작 / 잠깐 쉬기)
|
||||
- Primary 클릭 시 다음 목표로 교체(더미) + 시트 닫기
|
||||
- Secondary는 Break(더미) 또는 토스트 + 시트 닫기
|
||||
- 전역 블러/딤 금지, 모션 200~300ms 저자극
|
||||
- `/space` timer-complete finish
|
||||
- `/space` End Session finish
|
||||
- current-session thoughts restore
|
||||
- 결과 모달 -> `/app`
|
||||
- 제외 범위:
|
||||
- 서버/DB/통계/실제 타이머 로직 구현 금지
|
||||
- 새로운 stats/weekly review 기능 추가 금지
|
||||
- 완료 조건:
|
||||
- 완료 → 다음 목표 입력 → 바로 시작이 2~3스텝 내로 끝난다.
|
||||
- 종료 직후 중앙 결과 모달이 보인다
|
||||
- 결과 모달에 집중 시간/목표/thought가 맞게 보인다
|
||||
- 결과 모달 확인 후에만 `/app`으로 이동한다
|
||||
- 브라우저 재실행 뒤 current session이 살아 있으면 thought capture가 유지된다
|
||||
- 진행 상태:
|
||||
- 다음 작업
|
||||
- 검증:
|
||||
- npx tsc --noEmit
|
||||
- manual browser QA
|
||||
- 커밋 힌트:
|
||||
- feat(goal): Goal Complete Sheet로 다음 한 조각 루프 완성
|
||||
|
||||
---
|
||||
- docs(qa): completion result modal browser audit 기록
|
||||
|
||||
## 작업 2
|
||||
|
||||
- 제목: 세션 이어가기(Resume) — 새로고침/재진입 시 “지난 한 조각 이어서”
|
||||
- 제목: `/app` Atmosphere Entry Shell
|
||||
- 목적:
|
||||
- 출시 전이라도 “다시 들어왔을 때 바로 이어서 시작”이 되면 사용성이 급격히 좋아지고 재방문을 만든다.
|
||||
- `docs/screens/app/current/19_app_atmosphere_entry_spec.md` 기준으로 `/app` no-session 상태를 `goal + duration + atmosphere` 중심의 premium entry screen으로 재설계한다.
|
||||
- entry에서 scene/sound의 감각 품질을 다시 살리되 planner/dashboard 톤으로 흐르지 않게 만든다.
|
||||
- 변경 범위:
|
||||
- 로컬 저장(더미)으로 마지막 상태를 복원:
|
||||
- 마지막 목표, Scene, Timer, Sound, override flags
|
||||
- /space 진입 시 “지난 한 조각 이어서”를 조용한 CTA로 제공(Setup가 아니라 Focus 진입 직전에 1회)
|
||||
- 사용자가 거절하면 새 세션(목표 입력)로
|
||||
- 카피는 저자극/확정 표현 금지
|
||||
- no-session `/app` shell
|
||||
- atmosphere dummy 12개
|
||||
- goal input
|
||||
- duration input
|
||||
- 4x3 atmosphere grid
|
||||
- primary CTA
|
||||
- 제외 범위:
|
||||
- 로그인/서버 동기화 금지
|
||||
- `/space` recovery UX 재설계 금지
|
||||
- weekly review 상세 IA 변경 금지
|
||||
- server contract 변경 금지
|
||||
- 완료 조건:
|
||||
- 새로고침 후에도 마지막 세션이 이어지는 것처럼 보이고, 이어서 시작이 가능하다.
|
||||
- current session이 없을 때만 새 entry shell이 보인다
|
||||
- goal + duration + selected atmosphere가 start surface 안에서 명확히 읽힌다
|
||||
- 12개 dummy atmosphere가 4열 그리드로 배치된다
|
||||
- 진행 상태:
|
||||
- 구현 완료, visual premium polish 반영, browser QA 대기
|
||||
- 검증:
|
||||
- npx tsc --noEmit
|
||||
- `/app` no-session browser QA
|
||||
- 커밋 힌트:
|
||||
- feat(resume): 지난 세션 이어서(더미) 플로우 추가
|
||||
|
||||
---
|
||||
- feat(app): atmosphere entry shell 1차 구현
|
||||
|
||||
## 작업 3
|
||||
|
||||
- 제목: Recover 시그니처 — Notes(쓰기 전용) → Inbox(읽기/정리) + 30초 숨고르기 정리
|
||||
- 제목: `Custom Duration Contract`
|
||||
- 목적:
|
||||
- ADHD 타겟의 차별점은 “산만해져도 다시 돌아오는 비용”을 줄이는 것이다.
|
||||
- 쓰기와 읽기/정리를 분리해 몰입을 깨지 않게 한다.
|
||||
- `/app`의 분 단위 duration 입력을 실제 세션 길이로 반영한다.
|
||||
- 변경 범위:
|
||||
- Notes 팝오버는 쓰기 전용(리스트/정리 버튼 제거)
|
||||
- Inbox는 도크 시트에서 읽기/정리(완료/삭제 + Undo 더미)
|
||||
- 30초 숨고르기(더미) 흐름 정리:
|
||||
- 버튼 카피/위치/동작을 “다시 돌아오기” 느낌으로
|
||||
- 과한 UI 추가 금지
|
||||
- web start payload
|
||||
- server startSession contract
|
||||
- break duration 정책
|
||||
- `/space` timer 연동
|
||||
- 제외 범위:
|
||||
- 실제 타이머/오디오 로직 구현 금지
|
||||
- weekly review recommendation 확장 금지
|
||||
- atmosphere personalization 금지
|
||||
- 완료 조건:
|
||||
- Focus 중에는 쓰기만, 정리는 Inbox에서만 가능하며 복귀 흐름이 자연스럽다.
|
||||
- `70분` 같은 값이 실제 focus duration으로 반영된다
|
||||
- break duration이 정책 기준으로 계산된다
|
||||
- 진행 상태:
|
||||
- 구현 완료
|
||||
- 검증:
|
||||
- npx tsc --noEmit
|
||||
- start -> `/space` -> timer duration 확인
|
||||
- 커밋 힌트:
|
||||
- feat(recover): Notes→Inbox 복귀 흐름 및 30초 숨고르기 정리
|
||||
|
||||
---
|
||||
- feat(flow): custom duration contract 연결
|
||||
|
||||
## 작업 4
|
||||
|
||||
- 제목: Stage 폴리시 규칙 고정 + 마감(가독성/모션/레이어)
|
||||
- 제목: `Weekly Review Dock Reposition`
|
||||
- 목적:
|
||||
- Portal/LifeAt 느낌은 “미세한 마감”에서 결정된다.
|
||||
- 앞선 코어 동선이 확정된 후, 가독성과 모션/레이어를 일관되게 다듬는다.
|
||||
- 새 `/app` entry shell 안에서 weekly review를 start를 방해하지 않는 quiet secondary dock로 재배치한다.
|
||||
- 변경 범위:
|
||||
- 밝은/어두운 배경 모두에서 HUD/앵커 가독성 안정(전역 blur 금지, 로컬 스크림 최소)
|
||||
- 모션 200~300ms 저자극 통일
|
||||
- 아이콘/버튼 간격/재질 통일(글래스 톤)
|
||||
- `/app` review teaser placement
|
||||
- desktop/mobile responsive placement
|
||||
- review return hint placement
|
||||
- 제외 범위:
|
||||
- 기능 추가 금지(스타일/레이어만)
|
||||
- `/stats` IA 변경 금지
|
||||
- `/space` recovery UX 재설계 금지
|
||||
- 완료 조건:
|
||||
- 배경이 달라도 핵심 정보가 항상 읽히고, 전체가 프리미엄스럽게 정돈된다.
|
||||
- review entry는 항상 발견 가능하지만 start보다 앞서지 않는다
|
||||
- no-session shell 안에서만 quiet secondary dock로 읽힌다
|
||||
- 진행 상태:
|
||||
- 대기
|
||||
- 검증:
|
||||
- npx tsc --noEmit
|
||||
- `/app` no-session browser QA
|
||||
- 커밋 힌트:
|
||||
- style(stage): 가독성/모션/레이어 폴리시
|
||||
|
||||
---
|
||||
- fix(app): review dock 위치 재정렬
|
||||
|
||||
## 작업 5
|
||||
|
||||
- 제목: Pro/Paywall 최소 연결(의도 기반) — Packs/Profiles 중심
|
||||
- 제목: `Core Loop Alignment Audit` browser slice
|
||||
- 목적:
|
||||
- 기본 기능 잠금 없이, 확장/품질/개인화로 유료 이유를 만든다.
|
||||
- Focus를 방해하지 않고 클릭 의도 기반으로만 paywall을 연다.
|
||||
- 새 `/app` entry shell까지 포함한 핵심 흐름을 브라우저에서 실제로 검증한다.
|
||||
- 변경 범위:
|
||||
- Time 같은 기본 기능 LOCK 제거 유지
|
||||
- Pro는 Scene Packs / Sound Packs / Profile 저장으로 재배치
|
||||
- Paywall Sheet(더미) 구현: 잠긴 항목 클릭 시에만 노출
|
||||
- `/app` no-session
|
||||
- current session 상태에서 `/app -> /space` redirect
|
||||
- `/app -> /stats -> /app`
|
||||
- `/space` pause / return / next beat / complete
|
||||
- `/space` complete -> setup -> weekly review entry
|
||||
- 제외 범위:
|
||||
- 실제 결제 연동 금지
|
||||
- new feature 추가 금지
|
||||
- 완료 조건:
|
||||
- Pro가 “확장/팩/개인화”로 이해되고, Focus 흐름을 방해하지 않는다.
|
||||
- browser QA findings가 ledger에 반영된다
|
||||
- P1/P2 mismatch는 수정 대상 라운드로 분리된다
|
||||
- 검증:
|
||||
- npx tsc --noEmit
|
||||
- manual browser QA
|
||||
- 커밋 힌트:
|
||||
- feat(paywall): 의도 기반 Pro 진입/Paywall(더미) 연결
|
||||
- docs(qa): core-loop browser audit 기록
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"react-apple-signin-auth": "^1.1.2",
|
||||
"react-dom": "19.2.3",
|
||||
"react-facebook-login": "^4.1.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -6167,6 +6168,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
|
||||
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"react-apple-signin-auth": "^1.1.2",
|
||||
"react-dom": "19.2.3",
|
||||
"react-facebook-login": "^4.1.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { FocusDashboardWidget } from '@/widgets/focus-dashboard';
|
||||
|
||||
export default function AppPage() {
|
||||
redirect('/space');
|
||||
return <FocusDashboardWidget />;
|
||||
}
|
||||
|
||||
19
src/app/(app)/layout.tsx
Normal file
19
src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TOKEN_COOKIE_KEY } from "@/shared/config/authTokens";
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default async function AppLayout({ children }: AppLayoutProps) {
|
||||
const cookieStore = await cookies();
|
||||
const accessToken = cookieStore.get(TOKEN_COOKIE_KEY)?.value;
|
||||
|
||||
if (!accessToken) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,23 +1,26 @@
|
||||
import Link from "next/link";
|
||||
import { SocialLoginGroup } from "@/features/auth/components/SocialLoginGroup";
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = copy;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex flex-col justify-center items-center p-6 selection:bg-brand-soft/50">
|
||||
|
||||
{/* 상단 로고 (홈으로 돌아가기) */}
|
||||
<Link href="/" className="mb-12 text-2xl font-bold text-brand-dark tracking-tight flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||
<span className="text-3xl">🪴</span> VibeRoom
|
||||
<span className="text-3xl">🪴</span> {copy.appName}
|
||||
</Link>
|
||||
|
||||
{/* 로그인 카드 컨테이너 */}
|
||||
<div className="w-full max-w-md bg-white rounded-3xl shadow-sm border border-brand-dark/10 p-8 md:p-10">
|
||||
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-2xl font-bold text-brand-dark mb-3">다시 오셨군요!</h1>
|
||||
<h1 className="text-2xl font-bold text-brand-dark mb-3">{login.title}</h1>
|
||||
<p className="text-brand-dark/60 text-sm">
|
||||
비밀번호를 외울 필요 없이,<br />
|
||||
사용 중인 계정으로 3초 만에 시작하세요.
|
||||
{login.descriptionFirstLine}<br />
|
||||
{login.descriptionSecondLine}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -28,8 +31,8 @@ export default function LoginPage() {
|
||||
<SocialLoginGroup />
|
||||
|
||||
<div className="mt-10 text-center text-xs text-brand-dark/40 leading-relaxed">
|
||||
로그인함으로써 VibeRoom의 <br className="md:hidden" />
|
||||
<a href="#" className="underline hover:text-brand-dark/70">이용약관</a> 및 <a href="#" className="underline hover:text-brand-dark/70">개인정보처리방침</a>에 동의하게 됩니다.
|
||||
{login.agreementPrefix} <br className="md:hidden" />
|
||||
<a href="#" className="underline hover:text-brand-dark/70">{login.terms}</a> {login.agreementAnd} <a href="#" className="underline hover:text-brand-dark/70">{login.privacy}</a>{login.agreementSuffix}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { AuthLandingLoginButton } from "@/features/auth/components/AuthLandingLoginButton";
|
||||
import { AuthRedirectButton } from "@/features/auth/components/AuthRedirectButton";
|
||||
import { Button } from "@/shared/ui/Button";
|
||||
|
||||
export default function MarketingPage() {
|
||||
const { landing } = copy;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-brand-dark font-sans selection:bg-brand-soft/50">
|
||||
|
||||
@@ -9,14 +14,16 @@ export default function MarketingPage() {
|
||||
<header className="sticky top-0 z-50 w-full border-b border-brand-dark/10 bg-slate-50/80 backdrop-blur-md">
|
||||
<div className="container mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<Link href="/" className="text-xl font-bold text-brand-dark tracking-tight flex items-center gap-2">
|
||||
<span className="text-2xl">🪴</span> VibeRoom
|
||||
<span className="text-2xl">🪴</span> {copy.appName}
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-brand-dark/80">
|
||||
<Button variant="ghost" size="sm" href="#features">기능 소개</Button>
|
||||
<Button variant="ghost" size="sm" href="#pricing">요금제</Button>
|
||||
<Button variant="ghost" size="sm" href="/login">로그인</Button>
|
||||
<Button variant="primary" size="md" href="/space">무료로 시작하기</Button>
|
||||
<Button variant="ghost" size="sm" href="#features">{landing.nav.features}</Button>
|
||||
<Button variant="ghost" size="sm" href="#pricing">{landing.nav.pricing}</Button>
|
||||
<AuthLandingLoginButton variant="ghost" size="sm">
|
||||
{landing.nav.login}
|
||||
</AuthLandingLoginButton>
|
||||
<AuthRedirectButton variant="primary" size="md">{landing.nav.startFree}</AuthRedirectButton>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -27,19 +34,18 @@ export default function MarketingPage() {
|
||||
<div className="container mx-auto max-w-5xl flex flex-col md:flex-row items-center gap-16">
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold mb-6 tracking-tight leading-tight text-brand-dark">
|
||||
함께하는 조용한 몰입,<br />
|
||||
<span className="text-brand-primary">VibeRoom</span>
|
||||
{landing.hero.titleLead}<br />
|
||||
<span className="text-brand-primary">{landing.hero.titleAccent}</span>
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-brand-dark/70 mb-10 max-w-xl mx-auto md:mx-0 leading-relaxed">
|
||||
집중하기 어려운 순간, 당신을 다그치지 않는 편안한 공간으로 들어오세요.
|
||||
구조화된 코워킹 세션과 느슨한 연대가 당신의 페이스를 되찾아 줍니다.
|
||||
{landing.hero.description}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center md:justify-start">
|
||||
<Button variant="primary" size="lg" href="/space">
|
||||
나만의 공간 만들기
|
||||
</Button>
|
||||
<AuthRedirectButton variant="primary" size="lg">
|
||||
{landing.hero.primaryCta}
|
||||
</AuthRedirectButton>
|
||||
<Button variant="outline" size="lg" href="#features">
|
||||
더 알아보기
|
||||
{landing.hero.secondaryCta}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,7 +70,7 @@ export default function MarketingPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 h-12 bg-brand-soft/20 rounded-xl border border-brand-primary/20 flex items-center justify-center text-brand-primary font-medium text-sm">
|
||||
45:00 남음
|
||||
{landing.hero.timerPreview}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,23 +82,19 @@ export default function MarketingPage() {
|
||||
<section id="features" className="py-24 bg-white px-6">
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-brand-dark tracking-tight">당신을 위한 다정한 몰입 장치</h2>
|
||||
<p className="text-brand-dark/70 text-lg">단순한 타이머가 아닙니다. 무리하지 않고 오래 지속할 수 있는 환경을 제공합니다.</p>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-brand-dark tracking-tight">{landing.features.title}</h2>
|
||||
<p className="text-brand-dark/70 text-lg">{landing.features.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{ icon: "⏳", title: "구조화된 세션 타이머", desc: "부담 없이 시작할 수 있는 짧은 몰입과 확실한 휴식. 당신만의 작업 리듬을 부드럽게 설정하고 관리하세요." },
|
||||
{ icon: "🌱", title: "다정한 연대와 코워킹", desc: "화면 너머 누군가와 함께하는 바디 더블링 효과. 감시가 아닌, 조용하지만 강력한 동기를 서로 나누어보세요." },
|
||||
{ icon: "🛋️", title: "나만의 심미적 공간", desc: "비 오는 다락방, 햇살 드는 카페. 백색소음과 함께 내가 가장 편안함을 느끼는 가상 공간을 꾸미고 머무르세요." }
|
||||
].map((feature, idx) => (
|
||||
{landing.features.items.map((feature, idx) => (
|
||||
<div key={idx} className="p-8 bg-brand-soft/10 rounded-2xl border border-brand-soft/30 transition-transform hover:-translate-y-1 duration-300 hover:shadow-md">
|
||||
<div className="w-14 h-14 bg-white text-brand-primary rounded-2xl flex items-center justify-center mb-6 text-2xl shadow-sm border border-brand-dark/5">
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-dark mb-3">{feature.title}</h3>
|
||||
<p className="text-brand-dark/70 leading-relaxed">
|
||||
{feature.desc}
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -104,65 +106,64 @@ export default function MarketingPage() {
|
||||
<section id="pricing" className="py-24 px-6 bg-slate-50">
|
||||
<div className="container mx-auto max-w-5xl">
|
||||
<div className="text-center max-w-2xl mx-auto mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-brand-dark tracking-tight">나에게 맞는 공간 선택하기</h2>
|
||||
<p className="text-brand-dark/70 text-lg">개인의 가벼운 집중부터 프리랜서의 완벽한 워크스페이스까지.</p>
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-brand-dark tracking-tight">{landing.pricing.title}</h2>
|
||||
<p className="text-brand-dark/70 text-lg">{landing.pricing.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-center">
|
||||
{/* Starter Plan */}
|
||||
<div className="p-8 bg-white rounded-3xl border border-brand-dark/10 shadow-sm">
|
||||
<h3 className="text-xl font-bold text-brand-dark mb-2">Starter</h3>
|
||||
<p className="text-brand-dark/60 text-sm mb-6 h-10">가벼운 집중이 필요한 분</p>
|
||||
<h3 className="text-xl font-bold text-brand-dark mb-2">{landing.pricing.plans.starter.name}</h3>
|
||||
<p className="text-brand-dark/60 text-sm mb-6 h-10">{landing.pricing.plans.starter.subtitle}</p>
|
||||
<div className="mb-8">
|
||||
<span className="text-4xl font-bold text-brand-dark">무료</span>
|
||||
<span className="text-4xl font-bold text-brand-dark">{landing.pricing.plans.starter.price}</span>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-8 text-brand-dark/80 text-sm">
|
||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> 기본 가상 공간 테마</li>
|
||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> 1:1 파트너 매칭 (주 3회)</li>
|
||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> 오픈 코워킹 룸 입장</li>
|
||||
{landing.pricing.plans.starter.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-3"><span className="text-brand-primary">✓</span>{feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button variant="secondary" size="full" href="/space">
|
||||
무료로 시작하기
|
||||
</Button>
|
||||
<AuthRedirectButton variant="secondary" size="full">
|
||||
{landing.pricing.plans.starter.cta}
|
||||
</AuthRedirectButton>
|
||||
</div>
|
||||
|
||||
{/* Pro Plan */}
|
||||
<div className="p-8 bg-brand-dark rounded-3xl border border-brand-dark shadow-xl relative transform md:-translate-y-4">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-brand-primary text-white px-4 py-1 rounded-full text-xs font-bold tracking-wide">
|
||||
추천
|
||||
{landing.pricing.plans.pro.badge}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Pro</h3>
|
||||
<p className="text-white/60 text-sm mb-6 h-10">방해 없는 완벽한 몰입 환경</p>
|
||||
<h3 className="text-xl font-bold text-white mb-2">{landing.pricing.plans.pro.name}</h3>
|
||||
<p className="text-white/60 text-sm mb-6 h-10">{landing.pricing.plans.pro.subtitle}</p>
|
||||
<div className="mb-8 flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold text-white">₩6,900</span>
|
||||
<span className="text-white/60">/월</span>
|
||||
<span className="text-4xl font-bold text-white">{landing.pricing.plans.pro.price}</span>
|
||||
<span className="text-white/60">{landing.pricing.plans.pro.priceSuffix}</span>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-8 text-white/90 text-sm">
|
||||
<li className="flex items-center gap-3"><span className="text-brand-soft">✓</span> 프리미엄 테마 무제한</li>
|
||||
<li className="flex items-center gap-3"><span className="text-brand-soft">✓</span> 1:1 매칭 무제한</li>
|
||||
<li className="flex items-center gap-3"><span className="text-brand-soft">✓</span> 고급 집중 통계 및 리포트</li>
|
||||
<li className="flex items-center gap-3"><span className="text-brand-soft">✓</span> 공간 커스텀 아이템 제공</li>
|
||||
{landing.pricing.plans.pro.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-3"><span className="text-brand-soft">✓</span>{feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button variant="primary" size="full" href="/space">
|
||||
Pro 시작하기
|
||||
</Button>
|
||||
<AuthRedirectButton variant="primary" size="full">
|
||||
{landing.pricing.plans.pro.cta}
|
||||
</AuthRedirectButton>
|
||||
</div>
|
||||
|
||||
{/* Teams Plan */}
|
||||
<div className="p-8 bg-white rounded-3xl border border-brand-dark/10 shadow-sm">
|
||||
<h3 className="text-xl font-bold text-brand-dark mb-2">Teams</h3>
|
||||
<p className="text-brand-dark/60 text-sm mb-6 h-10">리모트 워크 기업 및 팀</p>
|
||||
<h3 className="text-xl font-bold text-brand-dark mb-2">{landing.pricing.plans.teams.name}</h3>
|
||||
<p className="text-brand-dark/60 text-sm mb-6 h-10">{landing.pricing.plans.teams.subtitle}</p>
|
||||
<div className="mb-8 flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold text-brand-dark">₩12,000</span>
|
||||
<span className="text-brand-dark/60 text-sm">/인·월</span>
|
||||
<span className="text-4xl font-bold text-brand-dark">{landing.pricing.plans.teams.price}</span>
|
||||
<span className="text-brand-dark/60 text-sm">{landing.pricing.plans.teams.priceSuffix}</span>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-8 text-brand-dark/80 text-sm">
|
||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> Pro 플랜의 모든 기능</li>
|
||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> 프라이빗 팀 스페이스</li>
|
||||
<li className="flex items-center gap-3"><span className="text-brand-primary">✓</span> 팀 전체 생산성 대시보드</li>
|
||||
{landing.pricing.plans.teams.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-3"><span className="text-brand-primary">✓</span>{feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button variant="secondary" size="full" href="#contact">
|
||||
도입 문의하기
|
||||
{landing.pricing.plans.teams.cta}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,31 +177,31 @@ export default function MarketingPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
|
||||
<div className="md:col-span-2">
|
||||
<Link href="/" className="text-xl font-bold text-white tracking-tight flex items-center gap-2 mb-4">
|
||||
<span className="text-2xl">🪴</span> VibeRoom
|
||||
<span className="text-2xl">🪴</span> {copy.appName}
|
||||
</Link>
|
||||
<p className="text-white/60 text-sm leading-relaxed max-w-xs">
|
||||
프리랜서와 온전한 집중이 필요한 분들을 위한 따뜻하고 구조화된 온라인 코워킹 스페이스입니다.
|
||||
{landing.footer.description}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-white mb-4">제품</h4>
|
||||
<h4 className="font-bold text-white mb-4">{landing.footer.productTitle}</h4>
|
||||
<ul className="space-y-3 text-sm text-white/60">
|
||||
<li><a href="#features" className="hover:text-brand-soft transition-colors">기능 소개</a></li>
|
||||
<li><a href="#pricing" className="hover:text-brand-soft transition-colors">요금제</a></li>
|
||||
<li><Link href="/login" className="hover:text-brand-soft transition-colors">웹앱 로그인</Link></li>
|
||||
<li><a href="#features" className="hover:text-brand-soft transition-colors">{landing.footer.links.features}</a></li>
|
||||
<li><a href="#pricing" className="hover:text-brand-soft transition-colors">{landing.footer.links.pricing}</a></li>
|
||||
<li><Link href="/login" className="hover:text-brand-soft transition-colors">{landing.footer.links.webLogin}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-white mb-4">회사</h4>
|
||||
<h4 className="font-bold text-white mb-4">{landing.footer.companyTitle}</h4>
|
||||
<ul className="space-y-3 text-sm text-white/60">
|
||||
<li><a href="#" className="hover:text-brand-soft transition-colors">소개</a></li>
|
||||
<li><a href="#" className="hover:text-brand-soft transition-colors">개인정보처리방침</a></li>
|
||||
<li><a href="#" className="hover:text-brand-soft transition-colors">이용약관</a></li>
|
||||
<li><a href="#" className="hover:text-brand-soft transition-colors">{landing.footer.links.about}</a></li>
|
||||
<li><a href="#" className="hover:text-brand-soft transition-colors">{landing.footer.links.privacy}</a></li>
|
||||
<li><a href="#" className="hover:text-brand-soft transition-colors">{landing.footer.links.terms}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-white/10 pt-8 text-center text-sm text-white/40">
|
||||
© 2026 VibeRoom. All rights reserved.
|
||||
{landing.footer.copyright}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
7
src/app/admin/page.tsx
Normal file
7
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { AdminConsoleWidget } from '@/widgets/admin-console';
|
||||
|
||||
export default function AdminPage() {
|
||||
return <AdminConsoleWidget />;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@theme {
|
||||
/* Noto Sans 다국어 폰트 적용 (next/font/google 변수) */
|
||||
@@ -81,6 +82,42 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.delay-150 {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
.delay-300 {
|
||||
animation-delay: 300ms;
|
||||
}
|
||||
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Noto_Sans_KR } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { Providers } from './providers';
|
||||
|
||||
// 1. Noto Sans KR 폰트 설정 (라틴어, 프랑스어, 한국어 등 다국어 지원 베이스)
|
||||
@@ -12,8 +13,8 @@ const notoSans = Noto_Sans_KR({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'VibeRoom - 당신만의 편안한 몰입 공간',
|
||||
description: '프리랜서와 온전한 집중이 필요한 분들을 위한 따뜻하고 구조화된 온라인 코워킹 스페이스. 작업 타이머, 세션 관리, 그리고 느슨한 연대를 통해 당신의 리듬을 찾아보세요.',
|
||||
title: copy.metadata.title,
|
||||
description: copy.metadata.description,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -22,7 +23,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ko" className={notoSans.variable}>
|
||||
<html lang="en" className={notoSans.variable}>
|
||||
<body className="antialiased font-sans">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
|
||||
2
src/entities/auth/index.ts
Normal file
2
src/entities/auth/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './model/types';
|
||||
export * from './model/useAuthStore';
|
||||
22
src/entities/auth/model/types.ts
Normal file
22
src/entities/auth/model/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface SocialLoginRequest {
|
||||
provider: 'google' | 'apple' | 'facebook';
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface PasswordLoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserMeResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
grade: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user?: UserMeResponse;
|
||||
}
|
||||
@@ -1,24 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import Cookies from 'js-cookie';
|
||||
import { AuthResponse } from '@/features/auth/types';
|
||||
import { create } from 'zustand';
|
||||
import { REFRESH_TOKEN_COOKIE_KEY, TOKEN_COOKIE_KEY } from '@/shared/config/authTokens';
|
||||
import type { AuthResponse } from './types';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
user: AuthResponse['user'] | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
// 액션
|
||||
setAuth: (data: AuthResponse) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
// 쿠키 키 상수 정의
|
||||
const TOKEN_COOKIE_KEY = 'vr_access_token';
|
||||
const REFRESH_TOKEN_COOKIE_KEY = 'vr_refresh_token';
|
||||
|
||||
/**
|
||||
* VibeRoom 전역 인증(Auth) 상태 저장소
|
||||
*/
|
||||
export const useAuthStore = create<AuthState>((set) => {
|
||||
const isClient = typeof window !== 'undefined';
|
||||
const savedToken = isClient ? Cookies.get(TOKEN_COOKIE_KEY) : null;
|
||||
@@ -26,36 +18,31 @@ export const useAuthStore = create<AuthState>((set) => {
|
||||
return {
|
||||
accessToken: savedToken || null,
|
||||
user: null,
|
||||
isAuthenticated: !!savedToken,
|
||||
|
||||
isAuthenticated: Boolean(savedToken),
|
||||
setAuth: (data: AuthResponse) => {
|
||||
const cookieOptions = {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict' as const
|
||||
sameSite: 'strict' as const,
|
||||
};
|
||||
|
||||
// 1. Access Token 저장 (7일)
|
||||
Cookies.set(TOKEN_COOKIE_KEY, data.accessToken, {
|
||||
...cookieOptions,
|
||||
expires: 7
|
||||
expires: 7,
|
||||
});
|
||||
|
||||
// 2. Refresh Token 저장 (30일)
|
||||
if (data.refreshToken) {
|
||||
Cookies.set(REFRESH_TOKEN_COOKIE_KEY, data.refreshToken, {
|
||||
...cookieOptions,
|
||||
expires: 30
|
||||
expires: 30,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 상태 업데이트
|
||||
set({
|
||||
accessToken: data.accessToken,
|
||||
user: data.user,
|
||||
isAuthenticated: true
|
||||
user: data.user ?? null,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
Cookies.remove(TOKEN_COOKIE_KEY);
|
||||
Cookies.remove(REFRESH_TOKEN_COOKIE_KEY);
|
||||
@@ -63,7 +50,7 @@ export const useAuthStore = create<AuthState>((set) => {
|
||||
set({
|
||||
accessToken: null,
|
||||
user: null,
|
||||
isAuthenticated: false
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
86
src/entities/focus-plan/api/focusPlanApi.ts
Normal file
86
src/entities/focus-plan/api/focusPlanApi.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { apiClient } from '@/shared/lib/apiClient';
|
||||
import type {
|
||||
CreateFocusPlanItemRequest,
|
||||
FocusPlanItem,
|
||||
FocusPlanItemStatus,
|
||||
FocusPlanToday,
|
||||
UpdateFocusPlanItemRequest,
|
||||
} from '../model/types';
|
||||
|
||||
interface RawFocusPlanItem {
|
||||
id: number;
|
||||
title: string;
|
||||
status: FocusPlanItemStatus;
|
||||
sortOrder: number;
|
||||
carriedOverFromDate: string | null;
|
||||
}
|
||||
|
||||
interface RawFocusPlanToday {
|
||||
date: string;
|
||||
currentItem: RawFocusPlanItem | null;
|
||||
nextItems: RawFocusPlanItem[];
|
||||
carriedOverCount: number;
|
||||
}
|
||||
|
||||
const normalizeFocusPlanItem = (item: RawFocusPlanItem): FocusPlanItem => {
|
||||
return {
|
||||
id: String(item.id),
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
order: item.sortOrder,
|
||||
carriedOverFromDate: item.carriedOverFromDate,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeFocusPlanToday = (plan: RawFocusPlanToday): FocusPlanToday => {
|
||||
return {
|
||||
date: plan.date,
|
||||
currentItem: plan.currentItem ? normalizeFocusPlanItem(plan.currentItem) : null,
|
||||
nextItems: plan.nextItems.map(normalizeFocusPlanItem),
|
||||
carriedOverCount: plan.carriedOverCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const focusPlanApi = {
|
||||
getToday: async (): Promise<FocusPlanToday> => {
|
||||
const response = await apiClient<RawFocusPlanToday>('api/v1/focus-plan/today', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return normalizeFocusPlanToday(response);
|
||||
},
|
||||
|
||||
createItem: async (payload: CreateFocusPlanItemRequest): Promise<FocusPlanToday> => {
|
||||
const response = await apiClient<RawFocusPlanToday>('api/v1/focus-plan/items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return normalizeFocusPlanToday(response);
|
||||
},
|
||||
|
||||
updateItem: async (itemId: string, payload: UpdateFocusPlanItemRequest): Promise<FocusPlanToday> => {
|
||||
const response = await apiClient<RawFocusPlanToday>(`api/v1/focus-plan/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return normalizeFocusPlanToday(response);
|
||||
},
|
||||
|
||||
completeItem: async (itemId: string): Promise<FocusPlanToday> => {
|
||||
const response = await apiClient<RawFocusPlanToday>(`api/v1/focus-plan/items/${itemId}/complete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return normalizeFocusPlanToday(response);
|
||||
},
|
||||
|
||||
deleteItem: async (itemId: string): Promise<FocusPlanToday> => {
|
||||
const response = await apiClient<RawFocusPlanToday>(`api/v1/focus-plan/items/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
return normalizeFocusPlanToday(response);
|
||||
},
|
||||
};
|
||||
3
src/entities/focus-plan/index.ts
Normal file
3
src/entities/focus-plan/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './api/focusPlanApi';
|
||||
export * from './model/types';
|
||||
export * from './model/useFocusPlan';
|
||||
26
src/entities/focus-plan/model/types.ts
Normal file
26
src/entities/focus-plan/model/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type FocusPlanItemStatus = 'pending' | 'completed';
|
||||
|
||||
export interface FocusPlanItem {
|
||||
id: string;
|
||||
title: string;
|
||||
status: FocusPlanItemStatus;
|
||||
order: number;
|
||||
carriedOverFromDate: string | null;
|
||||
}
|
||||
|
||||
export interface FocusPlanToday {
|
||||
date: string;
|
||||
currentItem: FocusPlanItem | null;
|
||||
nextItems: FocusPlanItem[];
|
||||
carriedOverCount: number;
|
||||
}
|
||||
|
||||
export interface CreateFocusPlanItemRequest {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface UpdateFocusPlanItemRequest {
|
||||
title?: string;
|
||||
sortOrder?: number;
|
||||
status?: FocusPlanItemStatus;
|
||||
}
|
||||
122
src/entities/focus-plan/model/useFocusPlan.ts
Normal file
122
src/entities/focus-plan/model/useFocusPlan.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { focusPlanApi } from '../api/focusPlanApi';
|
||||
import type {
|
||||
CreateFocusPlanItemRequest,
|
||||
FocusPlanItem,
|
||||
FocusPlanToday,
|
||||
UpdateFocusPlanItemRequest,
|
||||
} from './types';
|
||||
|
||||
const EMPTY_FOCUS_PLAN: FocusPlanToday = {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
currentItem: null,
|
||||
nextItems: [],
|
||||
carriedOverCount: 0,
|
||||
};
|
||||
|
||||
type FocusPlanSource = 'api' | 'unavailable';
|
||||
|
||||
interface UseFocusPlanResult {
|
||||
plan: FocusPlanToday;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
source: FocusPlanSource;
|
||||
refetch: () => Promise<FocusPlanToday | null>;
|
||||
createItem: (payload: CreateFocusPlanItemRequest) => Promise<FocusPlanToday | null>;
|
||||
updateItem: (itemId: string, payload: UpdateFocusPlanItemRequest) => Promise<FocusPlanToday | null>;
|
||||
completeItem: (itemId: string) => Promise<FocusPlanToday | null>;
|
||||
deleteItem: (itemId: string) => Promise<FocusPlanToday | null>;
|
||||
}
|
||||
|
||||
interface BuildFocusEntryStartHrefInput {
|
||||
goal: string;
|
||||
planItemId?: string | null;
|
||||
}
|
||||
|
||||
export const buildFocusEntryStartHref = ({ goal, planItemId }: BuildFocusEntryStartHrefInput) => {
|
||||
const params = new URLSearchParams({
|
||||
goal: goal.trim(),
|
||||
});
|
||||
|
||||
if (planItemId) {
|
||||
params.set('planItemId', planItemId);
|
||||
}
|
||||
|
||||
return `/space?${params.toString()}`;
|
||||
};
|
||||
|
||||
export const buildFocusPlanStartHref = (item: FocusPlanItem) => {
|
||||
return buildFocusEntryStartHref({
|
||||
goal: item.title,
|
||||
planItemId: item.id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useFocusPlan = (): UseFocusPlanResult => {
|
||||
const [plan, setPlan] = useState<FocusPlanToday>(EMPTY_FOCUS_PLAN);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const [isSaving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<FocusPlanSource>('unavailable');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const nextPlan = await focusPlanApi.getToday();
|
||||
setPlan(nextPlan);
|
||||
setSource('api');
|
||||
setError(null);
|
||||
return nextPlan;
|
||||
} catch (nextError) {
|
||||
const message = nextError instanceof Error ? nextError.message : '계획을 불러오지 못했어요.';
|
||||
setPlan(EMPTY_FOCUS_PLAN);
|
||||
setSource('unavailable');
|
||||
setError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const runMutation = useCallback(
|
||||
async (task: () => Promise<FocusPlanToday>) => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const nextPlan = await task();
|
||||
setPlan(nextPlan);
|
||||
setSource('api');
|
||||
setError(null);
|
||||
return nextPlan;
|
||||
} catch (nextError) {
|
||||
const message = nextError instanceof Error ? nextError.message : '계획을 저장하지 못했어요.';
|
||||
setError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
plan,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
source,
|
||||
refetch: load,
|
||||
createItem: async (payload) => runMutation(() => focusPlanApi.createItem(payload)),
|
||||
updateItem: async (itemId, payload) => runMutation(() => focusPlanApi.updateItem(itemId, payload)),
|
||||
completeItem: async (itemId) => runMutation(() => focusPlanApi.completeItem(itemId)),
|
||||
deleteItem: async (itemId) => runMutation(() => focusPlanApi.deleteItem(itemId)),
|
||||
};
|
||||
};
|
||||
65
src/entities/media/api/mediaManifestApi.ts
Normal file
65
src/entities/media/api/mediaManifestApi.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { DEFAULT_MEDIA_MANIFEST } from '../model/mockMediaManifest';
|
||||
import { normalizeMediaManifest } from '../model/resolveMediaAsset';
|
||||
import type { MediaManifest } from '../model/types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
const resolveMediaManifestUrl = () => {
|
||||
const explicitManifestUrl = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_URL;
|
||||
if (explicitManifestUrl) {
|
||||
return explicitManifestUrl;
|
||||
}
|
||||
|
||||
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
if (!apiBaseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${apiBaseUrl.replace(/\/$/, '')}/api/v1/media/manifest`;
|
||||
};
|
||||
|
||||
const resolveManifestFetchCache = (): RequestCache => {
|
||||
const configuredCacheMode = process.env.NEXT_PUBLIC_MEDIA_MANIFEST_FETCH_CACHE;
|
||||
|
||||
if (configuredCacheMode === 'no-store') {
|
||||
return configuredCacheMode;
|
||||
}
|
||||
|
||||
if (configuredCacheMode === 'force-cache') {
|
||||
return process.env.NODE_ENV === 'development' ? 'force-cache' : 'no-store';
|
||||
}
|
||||
|
||||
return 'no-store';
|
||||
};
|
||||
|
||||
export const MEDIA_MANIFEST_URL = resolveMediaManifestUrl();
|
||||
export const MEDIA_MANIFEST_FETCH_CACHE = resolveManifestFetchCache();
|
||||
|
||||
export const mediaManifestApi = {
|
||||
getManifest: async (signal?: AbortSignal): Promise<MediaManifest> => {
|
||||
if (!MEDIA_MANIFEST_URL) {
|
||||
return DEFAULT_MEDIA_MANIFEST;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(MEDIA_MANIFEST_URL, {
|
||||
method: 'GET',
|
||||
cache: MEDIA_MANIFEST_FETCH_CACHE,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${copy.media.manifestLoadFailed} (${response.status} at ${MEDIA_MANIFEST_URL})`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as Partial<MediaManifest>;
|
||||
return normalizeMediaManifest(payload);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(
|
||||
`${copy.media.manifestLoadFailed}: ${error instanceof Error ? error.message : String(error)} (URL: ${MEDIA_MANIFEST_URL})`
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
3
src/entities/media/index.ts
Normal file
3
src/entities/media/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './model/types';
|
||||
export * from './model/useMediaCatalog';
|
||||
export * from './model/resolveMediaAsset';
|
||||
26
src/entities/media/model/mockMediaManifest.ts
Normal file
26
src/entities/media/model/mockMediaManifest.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { SCENE_THEMES } from '@/entities/scene';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
import type { MediaManifest } from './types';
|
||||
|
||||
export const DEFAULT_MEDIA_MANIFEST: MediaManifest = {
|
||||
version: 'local-fallback-v1',
|
||||
updatedAt: '2026-03-09T00:00:00.000Z',
|
||||
cdnBaseUrl: null,
|
||||
scenes: SCENE_THEMES.map((scene) => ({
|
||||
sceneId: scene.id,
|
||||
cardUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||
stageUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||
mobileStageUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||
hdStageUrl: scene.managedCardPhotoUrl ?? scene.cardPhotoUrl,
|
||||
placeholderGradient: scene.previewGradient,
|
||||
})),
|
||||
sounds: SOUND_PRESETS.map((preset) => ({
|
||||
presetId: preset.id,
|
||||
previewUrl: null,
|
||||
loopUrl: null,
|
||||
fallbackLoopUrl: null,
|
||||
mimeType: null,
|
||||
durationSec: null,
|
||||
defaultVolume: null,
|
||||
})),
|
||||
};
|
||||
280
src/entities/media/model/resolveMediaAsset.ts
Normal file
280
src/entities/media/model/resolveMediaAsset.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { normalizeSceneId, type SceneTheme } from '@/entities/scene';
|
||||
import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
|
||||
import type {
|
||||
MediaManifest,
|
||||
SceneAssetManifestItem,
|
||||
SceneAssetMap,
|
||||
SoundAssetManifestItem,
|
||||
SoundAssetMap,
|
||||
} from './types';
|
||||
|
||||
const DEFAULT_STAGE_GRADIENT = 'linear-gradient(160deg, #1e293b 0%, #0f172a 100%)';
|
||||
|
||||
const normalizeSceneAssetId = (sceneId: string) => normalizeSceneId(sceneId) ?? sceneId;
|
||||
|
||||
const isAbsoluteUrl = (value: string) => /^(?:[a-z]+:)?\/\//i.test(value);
|
||||
|
||||
const appendCacheBustParam = (value: string, cacheBustKey?: string | null) => {
|
||||
if (!cacheBustKey) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = value.startsWith('/')
|
||||
? new URL(value, 'http://localhost')
|
||||
: new URL(value);
|
||||
|
||||
if (!url.searchParams.has('v')) {
|
||||
url.searchParams.set('v', cacheBustKey);
|
||||
}
|
||||
|
||||
if (value.startsWith('/')) {
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveAssetUrl = (
|
||||
value: string | null | undefined,
|
||||
baseUrl?: string | null,
|
||||
cacheBustKey?: string | null,
|
||||
) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsoluteUrl(value) || value.startsWith('/')) {
|
||||
return appendCacheBustParam(value, cacheBustKey);
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
return appendCacheBustParam(value, cacheBustKey);
|
||||
}
|
||||
|
||||
try {
|
||||
return appendCacheBustParam(
|
||||
new URL(value, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString(),
|
||||
cacheBustKey,
|
||||
);
|
||||
} catch {
|
||||
return appendCacheBustParam(value, cacheBustKey);
|
||||
}
|
||||
};
|
||||
|
||||
const mergeSceneAssets = (manifest: MediaManifest) => {
|
||||
const cacheBustKey = manifest.updatedAt || manifest.version;
|
||||
const bySceneId = new Map(
|
||||
DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => {
|
||||
const normalizedSceneId = normalizeSceneAssetId(asset.sceneId);
|
||||
|
||||
return [
|
||||
normalizedSceneId,
|
||||
{
|
||||
...asset,
|
||||
sceneId: normalizedSceneId,
|
||||
source: 'fallback',
|
||||
} as SceneAssetManifestItem,
|
||||
] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
for (const asset of manifest.scenes) {
|
||||
const normalizedSceneId = normalizeSceneAssetId(asset.sceneId);
|
||||
|
||||
bySceneId.set(normalizedSceneId, {
|
||||
...bySceneId.get(normalizedSceneId),
|
||||
...asset,
|
||||
sceneId: normalizedSceneId,
|
||||
source: 'remote',
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(bySceneId.values()).map((asset) => ({
|
||||
...asset,
|
||||
cardUrl: resolveAssetUrl(asset.cardUrl, manifest.cdnBaseUrl, cacheBustKey) ?? asset.cardUrl,
|
||||
stageUrl: resolveAssetUrl(asset.stageUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||
mobileStageUrl: resolveAssetUrl(asset.mobileStageUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||
hdStageUrl: resolveAssetUrl(asset.hdStageUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||
}));
|
||||
};
|
||||
|
||||
const mergeSoundAssets = (manifest: MediaManifest) => {
|
||||
const cacheBustKey = manifest.updatedAt || manifest.version;
|
||||
const byPresetId = new Map(
|
||||
DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [
|
||||
asset.presetId,
|
||||
{
|
||||
...asset,
|
||||
source: 'fallback',
|
||||
} as SoundAssetManifestItem,
|
||||
]),
|
||||
);
|
||||
|
||||
for (const asset of manifest.sounds) {
|
||||
byPresetId.set(asset.presetId, {
|
||||
...byPresetId.get(asset.presetId),
|
||||
...asset,
|
||||
source: 'remote',
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(byPresetId.values()).map((asset) => ({
|
||||
...asset,
|
||||
previewUrl: resolveAssetUrl(asset.previewUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||
loopUrl: resolveAssetUrl(asset.loopUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||
fallbackLoopUrl: resolveAssetUrl(asset.fallbackLoopUrl, manifest.cdnBaseUrl, cacheBustKey),
|
||||
}));
|
||||
};
|
||||
|
||||
export const normalizeMediaManifest = (manifest: Partial<MediaManifest> | null | undefined): MediaManifest => {
|
||||
const baseManifest: MediaManifest = {
|
||||
version: manifest?.version ?? DEFAULT_MEDIA_MANIFEST.version,
|
||||
updatedAt: manifest?.updatedAt ?? DEFAULT_MEDIA_MANIFEST.updatedAt,
|
||||
cdnBaseUrl: manifest?.cdnBaseUrl ?? DEFAULT_MEDIA_MANIFEST.cdnBaseUrl,
|
||||
scenes: manifest?.scenes ?? DEFAULT_MEDIA_MANIFEST.scenes,
|
||||
sounds: manifest?.sounds ?? DEFAULT_MEDIA_MANIFEST.sounds,
|
||||
};
|
||||
|
||||
return {
|
||||
...baseManifest,
|
||||
scenes: mergeSceneAssets(baseManifest),
|
||||
sounds: mergeSoundAssets(baseManifest),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSceneAssetMap = (manifest: MediaManifest): SceneAssetMap => {
|
||||
return manifest.scenes.reduce<SceneAssetMap>((accumulator, asset) => {
|
||||
accumulator[normalizeSceneAssetId(asset.sceneId)] = asset;
|
||||
return accumulator;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const buildSoundAssetMap = (manifest: MediaManifest): SoundAssetMap => {
|
||||
return manifest.sounds.reduce<SoundAssetMap>((accumulator, asset) => {
|
||||
accumulator[asset.presetId] = asset;
|
||||
return accumulator;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const getSceneCardPhotoUrl = (scene: SceneTheme, asset?: SceneAssetManifestItem | null) => {
|
||||
return asset?.cardUrl ?? scene.managedCardPhotoUrl ?? scene.cardPhotoUrl;
|
||||
};
|
||||
|
||||
export const getSceneStagePhotoUrl = (
|
||||
scene: SceneTheme,
|
||||
asset?: SceneAssetManifestItem | null,
|
||||
options?: { preferMobile?: boolean },
|
||||
) => {
|
||||
if (asset) {
|
||||
if (options?.preferMobile && asset.mobileStageUrl) {
|
||||
return asset.mobileStageUrl;
|
||||
}
|
||||
|
||||
if (asset.stageUrl) {
|
||||
return asset.stageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return getSceneCardPhotoUrl(scene, asset);
|
||||
};
|
||||
|
||||
export interface SceneStageLayerSources {
|
||||
placeholderGradient: string;
|
||||
underpaintUrl: string | null;
|
||||
baseImageUrl: string | null;
|
||||
finalImageUrl: string | null;
|
||||
}
|
||||
|
||||
export const resolveSceneStageLayerSources = (
|
||||
scene: SceneTheme,
|
||||
asset?: SceneAssetManifestItem | null,
|
||||
options?: { preferMobile?: boolean; preferHd?: boolean },
|
||||
): SceneStageLayerSources => {
|
||||
const placeholderGradient = asset?.placeholderGradient ?? scene.previewGradient ?? DEFAULT_STAGE_GRADIENT;
|
||||
const baseImageUrl =
|
||||
(options?.preferMobile ? asset?.mobileStageUrl : null) ??
|
||||
asset?.mobileStageUrl ??
|
||||
asset?.cardUrl ??
|
||||
scene.managedCardPhotoUrl ??
|
||||
scene.cardPhotoUrl;
|
||||
const preferredFinalImageUrl =
|
||||
(options?.preferHd ? asset?.hdStageUrl : null) ??
|
||||
asset?.stageUrl ??
|
||||
asset?.hdStageUrl ??
|
||||
null;
|
||||
const finalImageUrl =
|
||||
preferredFinalImageUrl && preferredFinalImageUrl !== baseImageUrl
|
||||
? preferredFinalImageUrl
|
||||
: null;
|
||||
const underpaintUrl = baseImageUrl ? null : asset?.blurDataUrl ?? null;
|
||||
|
||||
return {
|
||||
placeholderGradient,
|
||||
underpaintUrl,
|
||||
baseImageUrl,
|
||||
finalImageUrl,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSceneCardBackgroundStyle = (
|
||||
scene: SceneTheme,
|
||||
asset?: SceneAssetManifestItem | null,
|
||||
): CSSProperties => {
|
||||
return {
|
||||
backgroundImage: `url('${getSceneCardPhotoUrl(scene, asset)}')`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
};
|
||||
};
|
||||
|
||||
export const getSceneStageBackgroundStyle = (
|
||||
scene: SceneTheme,
|
||||
asset?: SceneAssetManifestItem | null,
|
||||
options?: { preferMobile?: boolean },
|
||||
): CSSProperties => {
|
||||
const stageUrl = getSceneStagePhotoUrl(scene, asset, options);
|
||||
const placeholderGradient = asset?.placeholderGradient ?? scene.previewGradient ?? DEFAULT_STAGE_GRADIENT;
|
||||
|
||||
return {
|
||||
backgroundImage: `url('${stageUrl}'), ${placeholderGradient}`,
|
||||
backgroundSize: 'cover, cover',
|
||||
backgroundPosition: 'center, center',
|
||||
backgroundRepeat: 'no-repeat, no-repeat',
|
||||
};
|
||||
};
|
||||
|
||||
export const preloadAssetImage = (url: string | null | undefined) => {
|
||||
if (!url || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const image = new window.Image();
|
||||
image.decoding = 'async';
|
||||
image.src = url;
|
||||
};
|
||||
|
||||
export const loadAssetImage = (url: string | null | undefined) => {
|
||||
if (!url || typeof window === 'undefined') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const image = new window.Image();
|
||||
image.decoding = 'async';
|
||||
|
||||
image.onload = () => {
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
image.onerror = () => {
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
image.src = url;
|
||||
});
|
||||
};
|
||||
32
src/entities/media/model/types.ts
Normal file
32
src/entities/media/model/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface SceneAssetManifestItem {
|
||||
sceneId: string;
|
||||
cardUrl: string;
|
||||
stageUrl?: string | null;
|
||||
mobileStageUrl?: string | null;
|
||||
hdStageUrl?: string | null;
|
||||
placeholderGradient?: string | null;
|
||||
blurDataUrl?: string | null;
|
||||
source?: 'fallback' | 'remote';
|
||||
}
|
||||
|
||||
export interface SoundAssetManifestItem {
|
||||
presetId: string;
|
||||
previewUrl?: string | null;
|
||||
loopUrl?: string | null;
|
||||
fallbackLoopUrl?: string | null;
|
||||
mimeType?: 'audio/mp4' | 'audio/mpeg' | 'audio/webm' | null;
|
||||
durationSec?: number | null;
|
||||
defaultVolume?: number | null;
|
||||
source?: 'fallback' | 'remote';
|
||||
}
|
||||
|
||||
export interface MediaManifest {
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
cdnBaseUrl?: string | null;
|
||||
scenes: SceneAssetManifestItem[];
|
||||
sounds: SoundAssetManifestItem[];
|
||||
}
|
||||
|
||||
export type SceneAssetMap = Record<string, SceneAssetManifestItem>;
|
||||
export type SoundAssetMap = Record<string, SoundAssetManifestItem>;
|
||||
194
src/entities/media/model/useMediaCatalog.ts
Normal file
194
src/entities/media/model/useMediaCatalog.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MEDIA_MANIFEST_URL, mediaManifestApi } from '../api/mediaManifestApi';
|
||||
import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
|
||||
import {
|
||||
buildSceneAssetMap,
|
||||
buildSoundAssetMap,
|
||||
normalizeMediaManifest,
|
||||
} from './resolveMediaAsset';
|
||||
import type { MediaManifest } from './types';
|
||||
|
||||
type MediaCatalogLoadResult = {
|
||||
manifest: MediaManifest;
|
||||
error: string | null;
|
||||
usedFallbackManifest: boolean;
|
||||
};
|
||||
|
||||
let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST);
|
||||
let manifestRequest: Promise<MediaCatalogLoadResult> | null = null;
|
||||
const MANIFEST_REVALIDATE_INTERVAL_MS = 60_000;
|
||||
|
||||
const readMediaManifest = async (signal?: AbortSignal): Promise<MediaCatalogLoadResult> => {
|
||||
if (!MEDIA_MANIFEST_URL) {
|
||||
return {
|
||||
manifest: manifestCache,
|
||||
error: null,
|
||||
usedFallbackManifest: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!manifestRequest) {
|
||||
manifestRequest = mediaManifestApi
|
||||
.getManifest(signal)
|
||||
.then((manifest) => {
|
||||
manifestCache = manifest;
|
||||
return {
|
||||
manifest,
|
||||
error: null,
|
||||
usedFallbackManifest: false,
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
// Only return abort errors up the chain if we're not using fallback
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextError = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Explicitly use normalize with null to get a pure fallback manifest
|
||||
const fallbackManifest = normalizeMediaManifest(null);
|
||||
|
||||
return {
|
||||
manifest: fallbackManifest,
|
||||
error: nextError,
|
||||
usedFallbackManifest: true,
|
||||
};
|
||||
})
|
||||
.finally(() => {
|
||||
manifestRequest = null;
|
||||
});
|
||||
}
|
||||
|
||||
return manifestRequest;
|
||||
};
|
||||
|
||||
export const useMediaCatalog = () => {
|
||||
const [manifest, setManifest] = useState<MediaManifest>(manifestCache);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [usedFallbackManifest, setUsedFallbackManifest] = useState(false);
|
||||
const [hasResolvedManifest, setHasResolvedManifest] = useState(!MEDIA_MANIFEST_URL);
|
||||
const isMountedRef = useRef(false);
|
||||
const lastRefreshAtRef = useRef(0);
|
||||
|
||||
const applyLoadResult = useCallback((result: MediaCatalogLoadResult) => {
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setManifest(result.manifest);
|
||||
setError(result.error);
|
||||
setUsedFallbackManifest(result.usedFallbackManifest);
|
||||
setHasResolvedManifest(true);
|
||||
}, []);
|
||||
|
||||
const refreshManifest = useCallback(async () => {
|
||||
try {
|
||||
lastRefreshAtRef.current = Date.now();
|
||||
const result = await readMediaManifest();
|
||||
applyLoadResult(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== 'AbortError' && isMountedRef.current) {
|
||||
console.error('Failed to refresh media manifest:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [applyLoadResult]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
const controller = new AbortController();
|
||||
|
||||
readMediaManifest(controller.signal)
|
||||
.then((result) => {
|
||||
if (!controller.signal.aborted) {
|
||||
applyLoadResult(result);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== 'AbortError' && !controller.signal.aborted) {
|
||||
console.error('Failed to load media manifest:', err);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [applyLoadResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!MEDIA_MANIFEST_URL || (!usedFallbackManifest && !error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void refreshManifest();
|
||||
}, 4_000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [error, refreshManifest, usedFallbackManifest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!MEDIA_MANIFEST_URL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeRefresh = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - lastRefreshAtRef.current < 15_000) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshManifest();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
maybeRefresh();
|
||||
};
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
maybeRefresh();
|
||||
}, MANIFEST_REVALIDATE_INTERVAL_MS);
|
||||
|
||||
window.addEventListener('focus', maybeRefresh);
|
||||
window.addEventListener('online', maybeRefresh);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener('focus', maybeRefresh);
|
||||
window.removeEventListener('online', maybeRefresh);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [refreshManifest]);
|
||||
|
||||
const sceneAssetMap = useMemo(() => buildSceneAssetMap(manifest), [manifest]);
|
||||
const soundAssetMap = useMemo(() => buildSoundAssetMap(manifest), [manifest]);
|
||||
|
||||
return {
|
||||
manifest,
|
||||
sceneAssetMap,
|
||||
soundAssetMap,
|
||||
error,
|
||||
usedFallbackManifest,
|
||||
hasResolvedManifest,
|
||||
refreshManifest,
|
||||
};
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './model/mockPlan';
|
||||
export * from './model/types';
|
||||
export * from './model/usePlanTier';
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import type { ProFeatureCard } from './types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export const PRO_LOCKED_ROOM_IDS: string[] = [];
|
||||
export const PRO_LOCKED_TIMER_LABELS: string[] = [];
|
||||
export const PRO_LOCKED_SOUND_IDS: string[] = [];
|
||||
|
||||
export const PRO_FEATURE_CARDS: ProFeatureCard[] = [
|
||||
{
|
||||
id: 'scene-packs',
|
||||
name: 'Scene Packs',
|
||||
description: '프리미엄 공간 묶음과 장면 변주',
|
||||
},
|
||||
{
|
||||
id: 'sound-packs',
|
||||
name: 'Sound Packs',
|
||||
description: '확장 사운드 프리셋 묶음',
|
||||
},
|
||||
{
|
||||
id: 'profiles',
|
||||
name: 'Profiles',
|
||||
description: '내 기본 세팅 저장/불러오기',
|
||||
},
|
||||
];
|
||||
export const PRO_FEATURE_CARDS: ProFeatureCard[] = [...copy.plan.proFeatureCards];
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface PlanLockedPack {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type ProFeatureId = 'scene-packs' | 'sound-packs' | 'profiles';
|
||||
export type ProFeatureId = 'daily-plan' | 'rituals' | 'weekly-review';
|
||||
|
||||
export interface ProFeatureCard {
|
||||
id: ProFeatureId;
|
||||
|
||||
77
src/entities/plan/model/usePlanTier.ts
Normal file
77
src/entities/plan/model/usePlanTier.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
import type { PlanTier } from './types';
|
||||
|
||||
const PLAN_TIER_STORAGE_KEY = 'viberoom:plan-tier:v1';
|
||||
const planTierSubscribers = new Set<() => void>();
|
||||
|
||||
const normalizePlanTier = (value: unknown): PlanTier => {
|
||||
return value === 'pro' ? 'pro' : 'normal';
|
||||
};
|
||||
|
||||
const notifyPlanTierSubscribers = () => {
|
||||
for (const subscriber of planTierSubscribers) {
|
||||
subscriber();
|
||||
}
|
||||
};
|
||||
|
||||
const subscribeToPlanTier = (onStoreChange: () => void) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== PLAN_TIER_STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
onStoreChange();
|
||||
};
|
||||
|
||||
planTierSubscribers.add(onStoreChange);
|
||||
window.addEventListener('storage', handleStorage);
|
||||
|
||||
return () => {
|
||||
planTierSubscribers.delete(onStoreChange);
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
};
|
||||
};
|
||||
|
||||
export const readStoredPlanTier = (): PlanTier => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizePlanTier(window.localStorage.getItem(PLAN_TIER_STORAGE_KEY));
|
||||
} catch {
|
||||
return 'normal';
|
||||
}
|
||||
};
|
||||
|
||||
export const usePlanTier = () => {
|
||||
const plan = useSyncExternalStore<PlanTier>(
|
||||
subscribeToPlanTier,
|
||||
readStoredPlanTier,
|
||||
() => 'normal',
|
||||
);
|
||||
|
||||
const setPlan = useCallback((nextPlan: PlanTier) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(PLAN_TIER_STORAGE_KEY, nextPlan);
|
||||
} finally {
|
||||
notifyPlanTierSubscribers();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
plan,
|
||||
isPro: plan === 'pro',
|
||||
setPlan,
|
||||
};
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './model/rooms';
|
||||
export * from './model/types';
|
||||
2
src/entities/scene/index.ts
Normal file
2
src/entities/scene/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './model/scenes';
|
||||
export * from './model/types';
|
||||
@@ -1,27 +1,30 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { RoomTheme } from './types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import type { SceneTheme } from './types';
|
||||
|
||||
const HUB_CURATION_ORDER = [
|
||||
'quiet-library',
|
||||
'rain-window',
|
||||
'dawn-cafe',
|
||||
'green-forest',
|
||||
'forest',
|
||||
'fireplace',
|
||||
] as const;
|
||||
|
||||
const HUB_RECOMMENDED_ROOM_COUNT = 3;
|
||||
const HUB_RECOMMENDED_SCENE_COUNT = 3;
|
||||
const SCENE_ID_ALIASES: Record<string, string> = {
|
||||
'green-forest': 'forest',
|
||||
};
|
||||
|
||||
export const ROOM_THEMES: RoomTheme[] = [
|
||||
export const SCENE_THEMES: SceneTheme[] = [
|
||||
{
|
||||
id: 'rain-window',
|
||||
name: '비 오는 창가',
|
||||
description: '빗소리 위로 스탠드 조명이 부드럽게 번집니다.',
|
||||
tags: ['저자극', '감성'],
|
||||
recommendedSound: 'Rain Focus',
|
||||
name: copy.scenes[0].name,
|
||||
description: copy.scenes[0].description,
|
||||
tags: [...copy.scenes[0].tags],
|
||||
recommendedSound: copy.scenes[0].recommendedSound,
|
||||
recommendedSoundPresetId: 'rain-focus',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: '밤',
|
||||
vibeLabel: '잔잔함',
|
||||
recommendedTime: copy.scenes[0].recommendedTime,
|
||||
vibeLabel: copy.scenes[0].vibeLabel,
|
||||
hubColor: '#D6E6F7',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/16763533/pexels-photo-16763533.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -36,14 +39,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'dawn-cafe',
|
||||
name: '새벽 카페',
|
||||
description: '첫 커피 향처럼 잔잔하고 따뜻한 좌석.',
|
||||
tags: ['감성', '딥워크'],
|
||||
recommendedSound: 'Cafe Murmur',
|
||||
name: copy.scenes[1].name,
|
||||
description: copy.scenes[1].description,
|
||||
tags: [...copy.scenes[1].tags],
|
||||
recommendedSound: copy.scenes[1].recommendedSound,
|
||||
recommendedSoundPresetId: 'cafe-work',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: '새벽',
|
||||
vibeLabel: '포근함',
|
||||
recommendedTime: copy.scenes[1].recommendedTime,
|
||||
vibeLabel: copy.scenes[1].vibeLabel,
|
||||
hubColor: '#F5DDCB',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/18340237/pexels-photo-18340237.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -58,14 +60,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'quiet-library',
|
||||
name: '도서관',
|
||||
description: '넘기는 종이 소리만 들리는 정돈된 책상.',
|
||||
tags: ['저자극', '딥워크'],
|
||||
recommendedSound: 'Deep White',
|
||||
name: copy.scenes[2].name,
|
||||
description: copy.scenes[2].description,
|
||||
tags: [...copy.scenes[2].tags],
|
||||
recommendedSound: copy.scenes[2].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: '오후',
|
||||
vibeLabel: '몰입',
|
||||
recommendedTime: copy.scenes[2].recommendedTime,
|
||||
vibeLabel: copy.scenes[2].vibeLabel,
|
||||
hubColor: '#DCE4D1',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/31390421/pexels-photo-31390421.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -80,14 +81,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'wave-sound',
|
||||
name: '파도 소리',
|
||||
description: '잔잔한 해변 위로 호흡을 고르는 공간.',
|
||||
tags: ['움직임 적음', '감성'],
|
||||
recommendedSound: 'Ocean Breath',
|
||||
name: copy.scenes[3].name,
|
||||
description: copy.scenes[3].description,
|
||||
tags: [...copy.scenes[3].tags],
|
||||
recommendedSound: copy.scenes[3].recommendedSound,
|
||||
recommendedSoundPresetId: 'ocean-calm',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: '밤',
|
||||
vibeLabel: '차분함',
|
||||
recommendedTime: copy.scenes[3].recommendedTime,
|
||||
vibeLabel: copy.scenes[3].vibeLabel,
|
||||
hubColor: '#CFE9EA',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/12715501/pexels-photo-12715501.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -101,15 +101,14 @@ export const ROOM_THEMES: RoomTheme[] = [
|
||||
'radial-gradient(125% 125% at 75% 8%, rgba(56,189,248,0.4) 0%, rgba(15,23,42,0) 50%), linear-gradient(145deg, rgba(8,47,73,0.93) 0%, rgba(15,23,42,0.9) 55%, rgba(3,7,18,0.96) 100%)',
|
||||
},
|
||||
{
|
||||
id: 'green-forest',
|
||||
name: '숲',
|
||||
description: '바람이 나뭇잎을 스치는 소리로 마음을 낮춥니다.',
|
||||
tags: ['저자극', '움직임 적음'],
|
||||
recommendedSound: 'Forest Hush',
|
||||
recommendedSoundPresetId: 'rain-focus',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: '오전',
|
||||
vibeLabel: '맑음',
|
||||
id: 'forest',
|
||||
name: copy.scenes[4].name,
|
||||
description: copy.scenes[4].description,
|
||||
tags: [...copy.scenes[4].tags],
|
||||
recommendedSound: copy.scenes[4].recommendedSound,
|
||||
recommendedSoundPresetId: 'forest-birds',
|
||||
recommendedTime: copy.scenes[4].recommendedTime,
|
||||
vibeLabel: copy.scenes[4].vibeLabel,
|
||||
hubColor: '#D1E7C9',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/34503448/pexels-photo-34503448.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -124,14 +123,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'fireplace',
|
||||
name: '벽난로',
|
||||
description: '작은 불꽃이 주는 리듬으로 집중을 붙잡습니다.',
|
||||
tags: ['감성', '저자극'],
|
||||
recommendedSound: 'Fireplace',
|
||||
name: copy.scenes[5].name,
|
||||
description: copy.scenes[5].description,
|
||||
tags: [...copy.scenes[5].tags],
|
||||
recommendedSound: copy.scenes[5].recommendedSound,
|
||||
recommendedSoundPresetId: 'fireplace',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: '밤',
|
||||
vibeLabel: '온기',
|
||||
recommendedTime: copy.scenes[5].recommendedTime,
|
||||
vibeLabel: copy.scenes[5].vibeLabel,
|
||||
hubColor: '#F2D4C0',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/14353716/pexels-photo-14353716.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -146,14 +144,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'city-night',
|
||||
name: '도시 야경',
|
||||
description: '유리창 너머 야경이 멀리 흐르는 고요한 밤.',
|
||||
tags: ['딥워크', '감성'],
|
||||
recommendedSound: 'Night Lo-fi',
|
||||
name: copy.scenes[6].name,
|
||||
description: copy.scenes[6].description,
|
||||
tags: [...copy.scenes[6].tags],
|
||||
recommendedSound: copy.scenes[6].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: '심야',
|
||||
vibeLabel: '고요함',
|
||||
recommendedTime: copy.scenes[6].recommendedTime,
|
||||
vibeLabel: copy.scenes[6].vibeLabel,
|
||||
hubColor: '#D9D3ED',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/17663181/pexels-photo-17663181.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -168,14 +165,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'snow-mountain',
|
||||
name: '설산',
|
||||
description: '차분한 공기와 선명한 수평선이 머리를 맑게 합니다.',
|
||||
tags: ['움직임 적음', '딥워크'],
|
||||
recommendedSound: 'Cold Wind',
|
||||
name: copy.scenes[7].name,
|
||||
description: copy.scenes[7].description,
|
||||
tags: [...copy.scenes[7].tags],
|
||||
recommendedSound: copy.scenes[7].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '50-10',
|
||||
recommendedTime: '새벽',
|
||||
vibeLabel: '선명함',
|
||||
recommendedTime: copy.scenes[7].recommendedTime,
|
||||
vibeLabel: copy.scenes[7].vibeLabel,
|
||||
hubColor: '#D8E7F3',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/34340672/pexels-photo-34340672.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -190,14 +186,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'sun-window',
|
||||
name: '창가',
|
||||
description: '햇살이 들어오는 간결한 책상, 부담 없는 시작.',
|
||||
tags: ['저자극', '딥워크'],
|
||||
recommendedSound: 'Soft Daylight',
|
||||
name: copy.scenes[8].name,
|
||||
description: copy.scenes[8].description,
|
||||
tags: [...copy.scenes[8].tags],
|
||||
recommendedSound: copy.scenes[8].recommendedSound,
|
||||
recommendedSoundPresetId: 'silent',
|
||||
recommendedTimerPresetId: '25-5',
|
||||
recommendedTime: '오후',
|
||||
vibeLabel: '가벼움',
|
||||
recommendedTime: copy.scenes[8].recommendedTime,
|
||||
vibeLabel: copy.scenes[8].vibeLabel,
|
||||
hubColor: '#F6EDC7',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/34833126/pexels-photo-34833126.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -212,14 +207,13 @@ export const ROOM_THEMES: RoomTheme[] = [
|
||||
},
|
||||
{
|
||||
id: 'outer-space',
|
||||
name: '우주',
|
||||
description: '별빛만 남긴 어둠 속에서 깊게 잠수합니다.',
|
||||
tags: ['딥워크', '감성'],
|
||||
recommendedSound: 'Deep Drone',
|
||||
name: copy.scenes[9].name,
|
||||
description: copy.scenes[9].description,
|
||||
tags: [...copy.scenes[9].tags],
|
||||
recommendedSound: copy.scenes[9].recommendedSound,
|
||||
recommendedSoundPresetId: 'deep-white',
|
||||
recommendedTimerPresetId: '90-20',
|
||||
recommendedTime: '심야',
|
||||
vibeLabel: '깊음',
|
||||
recommendedTime: copy.scenes[9].recommendedTime,
|
||||
vibeLabel: copy.scenes[9].vibeLabel,
|
||||
hubColor: '#D4DCF4',
|
||||
cardPhotoUrl:
|
||||
'https://images.pexels.com/photos/18537868/pexels-photo-18537868.jpeg?auto=compress&cs=tinysrgb&w=1400',
|
||||
@@ -234,65 +228,75 @@ export const ROOM_THEMES: RoomTheme[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const getRoomById = (roomId: string) => {
|
||||
return ROOM_THEMES.find((room) => room.id === roomId);
|
||||
export const getSceneById = (id: string) => {
|
||||
const normalizedSceneId = normalizeSceneId(id);
|
||||
return SCENE_THEMES.find((scene) => scene.id === normalizedSceneId);
|
||||
};
|
||||
|
||||
export const getRoomCardPhotoUrl = (room: RoomTheme) => {
|
||||
export const normalizeSceneId = (id: string | null | undefined) => {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SCENE_ID_ALIASES[id] ?? id;
|
||||
};
|
||||
|
||||
export const getSceneCardPhotoUrl = (scene: SceneTheme) => {
|
||||
// Swap managedCardPhotoUrl when you start serving first-party assets.
|
||||
return room.managedCardPhotoUrl ?? room.cardPhotoUrl;
|
||||
return scene.managedCardPhotoUrl ?? scene.cardPhotoUrl;
|
||||
};
|
||||
|
||||
export const getRoomCardBackgroundStyle = (room: RoomTheme): CSSProperties => {
|
||||
export const getSceneCardBackgroundStyle = (scene: SceneTheme): CSSProperties => {
|
||||
return {
|
||||
backgroundImage: `url('${getRoomCardPhotoUrl(room)}')`,
|
||||
backgroundImage: `url('${getSceneCardPhotoUrl(scene)}')`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
};
|
||||
};
|
||||
|
||||
export const getRoomBackgroundStyle = (room: RoomTheme): CSSProperties => {
|
||||
export const getSceneBackgroundStyle = (scene: SceneTheme): CSSProperties => {
|
||||
return {
|
||||
backgroundImage: `url('${getRoomCardPhotoUrl(room)}'), linear-gradient(160deg, #1e293b 0%, #0f172a 100%)`,
|
||||
backgroundImage: `url('${getSceneCardPhotoUrl(scene)}'), linear-gradient(160deg, #1e293b 0%, #0f172a 100%)`,
|
||||
backgroundSize: 'cover, cover',
|
||||
backgroundPosition: 'center, center',
|
||||
backgroundRepeat: 'no-repeat, no-repeat',
|
||||
};
|
||||
};
|
||||
|
||||
const uniqueByRoomId = (rooms: Array<RoomTheme | undefined>) => {
|
||||
const uniqueBySceneId = (scenes: Array<SceneTheme | undefined>) => {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return rooms.filter((room): room is RoomTheme => {
|
||||
if (!room || seen.has(room.id)) {
|
||||
return scenes.filter((scene): scene is SceneTheme => {
|
||||
if (!scene || seen.has(scene.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(room.id);
|
||||
seen.add(scene.id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const getHubRoomSections = (
|
||||
rooms: RoomTheme[],
|
||||
selectedRoomId: string,
|
||||
recommendedCount = HUB_RECOMMENDED_ROOM_COUNT,
|
||||
export const getHubSceneSections = (
|
||||
scenes: SceneTheme[],
|
||||
selectedSceneId: string,
|
||||
recommendedCount = HUB_RECOMMENDED_SCENE_COUNT,
|
||||
) => {
|
||||
const roomById = new Map(rooms.map((room) => [room.id, room] as const));
|
||||
const selectedRoom = roomById.get(selectedRoomId);
|
||||
const curatedRooms = HUB_CURATION_ORDER.map((id) => roomById.get(id));
|
||||
const sceneById = new Map(scenes.map((scene) => [scene.id, scene] as const));
|
||||
const normalizedSelectedSceneId = normalizeSceneId(selectedSceneId) ?? selectedSceneId;
|
||||
const selectedScene = sceneById.get(normalizedSelectedSceneId);
|
||||
const curatedScenes = HUB_CURATION_ORDER.map((id) => sceneById.get(id));
|
||||
|
||||
const recommendedRooms = uniqueByRoomId([
|
||||
selectedRoom,
|
||||
...curatedRooms,
|
||||
...rooms,
|
||||
const recommendedScenes = uniqueBySceneId([
|
||||
selectedScene,
|
||||
...curatedScenes,
|
||||
...scenes,
|
||||
]).slice(0, recommendedCount);
|
||||
|
||||
const recommendedRoomIds = new Set(recommendedRooms.map((room) => room.id));
|
||||
const allRooms = [...recommendedRooms, ...rooms.filter((room) => !recommendedRoomIds.has(room.id))];
|
||||
const recommendedSceneIds = new Set(recommendedScenes.map((scene) => scene.id));
|
||||
const allScenes = [...recommendedScenes, ...scenes.filter((scene) => !recommendedSceneIds.has(scene.id))];
|
||||
|
||||
return {
|
||||
recommendedRooms,
|
||||
allRooms,
|
||||
recommendedScenes,
|
||||
allScenes,
|
||||
};
|
||||
};
|
||||
@@ -1,19 +1,18 @@
|
||||
export type RoomTag = '저자극' | '움직임 적음' | '딥워크' | '감성';
|
||||
export type SceneTag = '저자극' | '움직임 적음' | '딥워크' | '감성';
|
||||
|
||||
export interface RoomPresence {
|
||||
export interface ScenePresence {
|
||||
focus: number;
|
||||
break: number;
|
||||
away: number;
|
||||
}
|
||||
|
||||
export interface RoomTheme {
|
||||
export interface SceneTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: RoomTag[];
|
||||
tags: SceneTag[];
|
||||
recommendedSound: string;
|
||||
recommendedSoundPresetId: string;
|
||||
recommendedTimerPresetId: string;
|
||||
recommendedTime: string;
|
||||
vibeLabel: string;
|
||||
hubColor: string;
|
||||
@@ -21,7 +20,7 @@ export interface RoomTheme {
|
||||
googleImageSearchUrl: string;
|
||||
managedCardPhotoUrl: string | null;
|
||||
activeMembers: number;
|
||||
presence: RoomPresence;
|
||||
presence: ScenePresence;
|
||||
previewImage: string;
|
||||
previewGradient: string;
|
||||
}
|
||||
57
src/entities/session/api/inboxApi.ts
Normal file
57
src/entities/session/api/inboxApi.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { apiClient } from '@/shared/lib/apiClient';
|
||||
|
||||
export interface InboxThoughtResponse {
|
||||
id: string;
|
||||
text: string;
|
||||
sceneName: string;
|
||||
isCompleted: boolean;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateInboxThoughtRequest {
|
||||
text: string;
|
||||
sceneName: string;
|
||||
}
|
||||
|
||||
export interface UpdateInboxThoughtRequest {
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
export const inboxApi = {
|
||||
getThoughts: async (): Promise<InboxThoughtResponse[]> => {
|
||||
return apiClient<InboxThoughtResponse[]>('api/v1/inbox/thoughts', {
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
|
||||
addThought: async (payload: CreateInboxThoughtRequest): Promise<InboxThoughtResponse> => {
|
||||
return apiClient<InboxThoughtResponse>('api/v1/inbox/thoughts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
updateThoughtComplete: async (
|
||||
thoughtId: string,
|
||||
payload: UpdateInboxThoughtRequest,
|
||||
): Promise<InboxThoughtResponse> => {
|
||||
return apiClient<InboxThoughtResponse>(`api/v1/inbox/thoughts/${thoughtId}/complete`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
deleteThought: async (thoughtId: string): Promise<void> => {
|
||||
return apiClient<void>(`api/v1/inbox/thoughts/${thoughtId}`, {
|
||||
method: 'DELETE',
|
||||
expectNoContent: true,
|
||||
});
|
||||
},
|
||||
|
||||
clearThoughts: async (): Promise<void> => {
|
||||
return apiClient<void>('api/v1/inbox/thoughts', {
|
||||
method: 'DELETE',
|
||||
expectNoContent: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -5,92 +5,23 @@ import type {
|
||||
RecentThought,
|
||||
ReactionOption,
|
||||
SoundPreset,
|
||||
TimerPreset,
|
||||
} from './types';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export const TODAY_ONE_LINER = '오늘의 한 줄: 완벽보다 시작, 한 조각이면 충분해요.';
|
||||
export const TODAY_ONE_LINER = copy.session.todayOneLiner;
|
||||
|
||||
export const GOAL_CHIPS: GoalChip[] = [
|
||||
{ id: 'mail-3', label: '메일 3개' },
|
||||
{ id: 'doc-1p', label: '문서 1p' },
|
||||
{ id: 'code-1-function', label: '코딩 1함수' },
|
||||
{ id: 'tidy-10m', label: '정리 10분' },
|
||||
{ id: 'reading-15m', label: '독서 15분' },
|
||||
{ id: 'resume-1paragraph', label: '이력서 1문단' },
|
||||
];
|
||||
export const GOAL_CHIPS: GoalChip[] = [...copy.session.goalChips];
|
||||
|
||||
export const CHECK_IN_PHRASES: CheckInPhrase[] = [
|
||||
{ id: 'arrived', text: '지금 들어왔어요' },
|
||||
{ id: 'sprint-25', text: '25분만 달릴게요' },
|
||||
{ id: 'on-break', text: '휴식 중' },
|
||||
{ id: 'back-focus', text: '다시 집중!' },
|
||||
{ id: 'slow-day', text: '오늘은 천천히' },
|
||||
];
|
||||
export const CHECK_IN_PHRASES: CheckInPhrase[] = [...copy.session.checkInPhrases];
|
||||
|
||||
export const REACTION_OPTIONS: ReactionOption[] = [
|
||||
{ id: 'thumbs-up', emoji: '👍', label: '응원해요' },
|
||||
{ id: 'fire', emoji: '🔥', label: '집중 모드' },
|
||||
{ id: 'clap', emoji: '👏', label: '잘하고 있어요' },
|
||||
{ id: 'heart-hands', emoji: '🫶', label: '연결되어 있어요' },
|
||||
];
|
||||
export const REACTION_OPTIONS: ReactionOption[] = [...copy.session.reactionOptions];
|
||||
|
||||
export const SOUND_PRESETS: SoundPreset[] = [
|
||||
{ id: 'deep-white', label: 'Deep White' },
|
||||
{ id: 'rain-focus', label: 'Rain Focus' },
|
||||
{ id: 'cafe-work', label: 'Cafe Work' },
|
||||
{ id: 'ocean-calm', label: 'Ocean Calm' },
|
||||
{ id: 'fireplace', label: 'Fireplace' },
|
||||
{ id: 'silent', label: 'Silent' },
|
||||
];
|
||||
export const SOUND_PRESETS: SoundPreset[] = [...copy.session.soundPresets];
|
||||
|
||||
export const TIMER_PRESETS: TimerPreset[] = [
|
||||
{ id: '25-5', label: '25/5', focusMinutes: 25, breakMinutes: 5 },
|
||||
{ id: '50-10', label: '50/10', focusMinutes: 50, breakMinutes: 10 },
|
||||
{ id: '90-20', label: '90/20', focusMinutes: 90, breakMinutes: 20 },
|
||||
{ id: 'custom', label: '커스텀' },
|
||||
];
|
||||
export const DISTRACTION_DUMP_PLACEHOLDER = [...copy.session.distractionDumpPlaceholder];
|
||||
|
||||
export const DISTRACTION_DUMP_PLACEHOLDER = [
|
||||
'디자인 QA 요청 확인',
|
||||
'세금계산서 발행 메모',
|
||||
'오후 미팅 질문 1개 정리',
|
||||
];
|
||||
export const TODAY_STATS: FocusStatCard[] = [...copy.session.todayStats];
|
||||
|
||||
export const TODAY_STATS: FocusStatCard[] = [
|
||||
{ id: 'today-focus', label: '오늘 집중 시간', value: '2h 40m', delta: '+35m' },
|
||||
{ id: 'today-cycles', label: '완료한 사이클', value: '5회', delta: '+1' },
|
||||
{ id: 'today-entry', label: '입장 횟수', value: '3회', delta: '유지' },
|
||||
];
|
||||
export const WEEKLY_STATS: FocusStatCard[] = [...copy.session.weeklyStats];
|
||||
|
||||
export const WEEKLY_STATS: FocusStatCard[] = [
|
||||
{ id: 'week-focus', label: '최근 7일 집중 시간', value: '14h 20m', delta: '+2h 10m' },
|
||||
{ id: 'week-best-day', label: '최고 몰입일', value: '수요일', delta: '3h 30m' },
|
||||
{ id: 'week-consistency', label: '연속 달성', value: '4일', delta: '+1일' },
|
||||
];
|
||||
|
||||
export const RECENT_THOUGHTS: RecentThought[] = [
|
||||
{
|
||||
id: 'thought-1',
|
||||
text: '내일 미팅 전에 제안서 첫 문단만 다시 다듬기',
|
||||
roomName: '도서관',
|
||||
capturedAt: '방금 전',
|
||||
},
|
||||
{
|
||||
id: 'thought-2',
|
||||
text: '기획 문서의 핵심 흐름을 한 문장으로 정리해두기',
|
||||
roomName: '비 오는 창가',
|
||||
capturedAt: '24분 전',
|
||||
},
|
||||
{
|
||||
id: 'thought-3',
|
||||
text: '오후에 확인할 이슈 번호만 메모하고 지금 작업 복귀',
|
||||
roomName: '숲',
|
||||
capturedAt: '1시간 전',
|
||||
},
|
||||
{
|
||||
id: 'thought-4',
|
||||
text: '리뷰 코멘트는 오늘 17시 이후에 한 번에 처리',
|
||||
roomName: '벽난로',
|
||||
capturedAt: '어제',
|
||||
},
|
||||
];
|
||||
export const RECENT_THOUGHTS: RecentThought[] = [...copy.session.recentThoughts];
|
||||
|
||||
@@ -19,13 +19,6 @@ export interface SoundPreset {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TimerPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
focusMinutes?: number;
|
||||
breakMinutes?: number;
|
||||
}
|
||||
|
||||
export interface FocusStatCard {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -36,7 +29,7 @@ export interface FocusStatCard {
|
||||
export interface RecentThought {
|
||||
id: string;
|
||||
text: string;
|
||||
roomName: string;
|
||||
sceneName: string;
|
||||
capturedAt: string;
|
||||
isCompleted?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,97 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { inboxApi } from '../api/inboxApi';
|
||||
import type { RecentThought } from './types';
|
||||
|
||||
const THOUGHT_INBOX_STORAGE_KEY = 'viberoom:thought-inbox:v1';
|
||||
const MAX_THOUGHT_INBOX_ITEMS = 40;
|
||||
|
||||
const readStoredThoughts = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(THOUGHT_INBOX_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.filter((thought): thought is RecentThought => {
|
||||
return (
|
||||
thought &&
|
||||
typeof thought.id === 'string' &&
|
||||
typeof thought.text === 'string' &&
|
||||
typeof thought.roomName === 'string' &&
|
||||
typeof thought.capturedAt === 'string' &&
|
||||
(typeof thought.isCompleted === 'undefined' || typeof thought.isCompleted === 'boolean')
|
||||
);
|
||||
const normalizeThought = (thought: {
|
||||
id: string;
|
||||
text: string;
|
||||
sceneName: string;
|
||||
isCompleted: boolean;
|
||||
capturedAt: string;
|
||||
}): RecentThought => ({
|
||||
id: thought.id,
|
||||
text: thought.text,
|
||||
sceneName: thought.sceneName,
|
||||
isCompleted: thought.isCompleted,
|
||||
capturedAt: thought.capturedAt,
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const persistThoughts = (thoughts: RecentThought[]) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(THOUGHT_INBOX_STORAGE_KEY, JSON.stringify(thoughts));
|
||||
};
|
||||
|
||||
export const useThoughtInbox = () => {
|
||||
const [thoughts, setThoughts] = useState<RecentThought[]>(() => readStoredThoughts());
|
||||
const [thoughts, setThoughts] = useState<RecentThought[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
persistThoughts(thoughts);
|
||||
}, [thoughts]);
|
||||
let mounted = true;
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== THOUGHT_INBOX_STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
setThoughts(readStoredThoughts());
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
inboxApi.getThoughts()
|
||||
.then((data) => {
|
||||
if (!mounted) return;
|
||||
setThoughts(data.map(normalizeThought));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load inbox thoughts:', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addThought = useCallback((text: string, roomName: string) => {
|
||||
const addThought = useCallback(async (text: string, sceneName: string) => {
|
||||
const trimmedText = text.trim();
|
||||
|
||||
if (!trimmedText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tempId = `thought-temp-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
|
||||
const thought: RecentThought = {
|
||||
id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
id: tempId,
|
||||
text: trimmedText,
|
||||
roomName,
|
||||
capturedAt: '방금 전',
|
||||
sceneName,
|
||||
capturedAt: copy.session.justNow,
|
||||
isCompleted: false,
|
||||
};
|
||||
|
||||
setThoughts((current) => {
|
||||
const next: RecentThought[] = [thought, ...current].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
return thought;
|
||||
try {
|
||||
const response = await inboxApi.addThought({ text: trimmedText, sceneName });
|
||||
const normalized = normalizeThought(response);
|
||||
|
||||
setThoughts((current) =>
|
||||
current.map((t) => (t.id === tempId ? normalized : t))
|
||||
);
|
||||
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
console.error('Failed to add thought:', err);
|
||||
setThoughts((current) => current.filter((t) => t.id !== tempId));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeThought = useCallback((thoughtId: string) => {
|
||||
@@ -109,6 +94,12 @@ export const useThoughtInbox = () => {
|
||||
return current.filter((thought) => thought.id !== thoughtId);
|
||||
});
|
||||
|
||||
if (removedThought && !thoughtId.startsWith('thought-temp-')) {
|
||||
inboxApi.deleteThought(thoughtId).catch((err) => {
|
||||
console.error('Failed to delete thought:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return removedThought;
|
||||
}, []);
|
||||
|
||||
@@ -120,6 +111,10 @@ export const useThoughtInbox = () => {
|
||||
return [];
|
||||
});
|
||||
|
||||
inboxApi.clearThoughts().catch((err) => {
|
||||
console.error('Failed to clear thoughts:', err);
|
||||
});
|
||||
|
||||
return snapshot;
|
||||
}, []);
|
||||
|
||||
@@ -128,12 +123,36 @@ export const useThoughtInbox = () => {
|
||||
const withoutDuplicate = current.filter((currentThought) => currentThought.id !== thought.id);
|
||||
return [thought, ...withoutDuplicate].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
||||
});
|
||||
|
||||
inboxApi.addThought({ text: thought.text, sceneName: thought.sceneName })
|
||||
.then((res) => {
|
||||
setThoughts((current) =>
|
||||
current.map((t) => (t.id === thought.id ? { ...t, id: res.id } : t))
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to restore thought:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const restoreThoughts = useCallback((snapshot: RecentThought[]) => {
|
||||
setThoughts(() => {
|
||||
return snapshot.slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
||||
});
|
||||
|
||||
// Fire and forget bulk restore as we don't have a bulk API
|
||||
Promise.all(
|
||||
snapshot.map((t) => inboxApi.addThought({ text: t.text, sceneName: t.sceneName }))
|
||||
)
|
||||
.then(() => {
|
||||
return inboxApi.getThoughts();
|
||||
})
|
||||
.then((data) => {
|
||||
setThoughts(data.map(normalizeThought));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to restore thoughts:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setThoughtCompleted = useCallback((thoughtId: string, isCompleted: boolean) => {
|
||||
@@ -156,6 +175,18 @@ export const useThoughtInbox = () => {
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!thoughtId.startsWith('thought-temp-')) {
|
||||
inboxApi.updateThoughtComplete(thoughtId, { isCompleted }).catch((err) => {
|
||||
console.error('Failed to update thought completion:', err);
|
||||
// Optionally revert state on failure
|
||||
if (previousThought) {
|
||||
setThoughts((current) => {
|
||||
return current.map(t => t.id === thoughtId ? { ...t, isCompleted: !isCompleted } : t);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return previousThought;
|
||||
}, []);
|
||||
|
||||
|
||||
145
src/features/admin/api/adminApi.ts
Normal file
145
src/features/admin/api/adminApi.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { AuthResponse } from '@/entities/auth';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ApiErrorPayload {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SceneMediaAssetUploadResponse {
|
||||
sceneId: string;
|
||||
cardObjectKey: string;
|
||||
cardUrl: string;
|
||||
stageObjectKey: string;
|
||||
stageUrl: string;
|
||||
mobileStageObjectKey?: string | null;
|
||||
mobileStageUrl?: string | null;
|
||||
hdStageObjectKey?: string | null;
|
||||
hdStageUrl?: string | null;
|
||||
placeholderGradient?: string | null;
|
||||
blurDataUrl?: string | null;
|
||||
assetVersion: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SoundMediaAssetUploadResponse {
|
||||
presetId: string;
|
||||
previewObjectKey?: string | null;
|
||||
previewUrl?: string | null;
|
||||
loopObjectKey: string;
|
||||
loopUrl: string;
|
||||
fallbackLoopObjectKey?: string | null;
|
||||
fallbackLoopUrl?: string | null;
|
||||
mimeType?: string | null;
|
||||
durationSec?: number | null;
|
||||
defaultVolume?: number | null;
|
||||
assetVersion: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminLoginInput {
|
||||
loginId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const buildUrl = (endpoint: string) => {
|
||||
return `${API_BASE_URL}${endpoint.startsWith('/') ? endpoint : `/${endpoint}`}`;
|
||||
};
|
||||
|
||||
const parseErrorMessage = async (response: Response) => {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
return copy.common.requestFailed(response.status);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await response.json()) as ApiErrorPayload;
|
||||
return payload.message ?? payload.error ?? copy.common.requestFailed(response.status);
|
||||
} catch {
|
||||
return copy.common.requestFailed(response.status);
|
||||
}
|
||||
};
|
||||
|
||||
const unwrapPayload = <T>(payload: T | ApiEnvelope<T>) => {
|
||||
if (payload && typeof payload === 'object' && 'data' in payload) {
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
};
|
||||
|
||||
const fetchWithToken = async <T>(
|
||||
endpoint: string,
|
||||
accessToken: string,
|
||||
body: FormData,
|
||||
): Promise<T> => {
|
||||
const response = await fetch(buildUrl(endpoint), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseErrorMessage(response));
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as T | ApiEnvelope<T>;
|
||||
return unwrapPayload(payload);
|
||||
};
|
||||
|
||||
export const adminApi = {
|
||||
login: async ({ loginId, password }: AdminLoginInput): Promise<AuthResponse> => {
|
||||
const response = await fetch(buildUrl('/api/v1/auth/login'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: loginId,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseErrorMessage(response));
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as AuthResponse | ApiEnvelope<AuthResponse>;
|
||||
return unwrapPayload(payload);
|
||||
},
|
||||
|
||||
uploadScene: async (
|
||||
sceneId: string,
|
||||
formData: FormData,
|
||||
accessToken: string,
|
||||
): Promise<SceneMediaAssetUploadResponse> => {
|
||||
return fetchWithToken<SceneMediaAssetUploadResponse>(
|
||||
`/api/v1/admin/media/scenes/${encodeURIComponent(sceneId)}`,
|
||||
accessToken,
|
||||
formData,
|
||||
);
|
||||
},
|
||||
|
||||
uploadSound: async (
|
||||
presetId: string,
|
||||
formData: FormData,
|
||||
accessToken: string,
|
||||
): Promise<SoundMediaAssetUploadResponse> => {
|
||||
return fetchWithToken<SoundMediaAssetUploadResponse>(
|
||||
`/api/v1/admin/media/sounds/${encodeURIComponent(presetId)}`,
|
||||
accessToken,
|
||||
formData,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,17 +1,73 @@
|
||||
import { apiClient } from "@/shared/lib/apiClient";
|
||||
import { AuthResponse, SocialLoginRequest } from "../types";
|
||||
import { apiClient } from '@/shared/lib/apiClient';
|
||||
import type { AuthResponse, PasswordLoginRequest, SocialLoginRequest, UserMeResponse } from '../types';
|
||||
|
||||
interface RefreshTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
/**
|
||||
* 프론트엔드에서 발급받은 소셜 토큰을 백엔드로 전송하여 VibeRoom 전용 토큰으로 교환합니다.
|
||||
* @param data 구글/애플/페이스북에서 발급받은 Provider 이름과 Token
|
||||
* Backend Codex:
|
||||
* - 로컬 계정(email/password)으로 로그인하고 VibeRoom access/refresh token을 발급한다.
|
||||
* - 응답에는 accessToken, refreshToken, user를 포함한다.
|
||||
* - user는 최소 id, name, email, grade를 포함해 로그인 직후 권한 UI를 판별할 수 있어야 한다.
|
||||
*/
|
||||
loginWithSocial: async (data: SocialLoginRequest): Promise<AuthResponse> => {
|
||||
return apiClient<AuthResponse>("api/v1/auth/social", {
|
||||
method: "POST",
|
||||
loginWithPassword: async (data: PasswordLoginRequest): Promise<AuthResponse> => {
|
||||
return apiClient<AuthResponse>('api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
// TODO: 이후 필요 시 logout, refreshAccessToken 등 인증 관련 API 추가
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 구글/애플/페이스북에서 받은 소셜 토큰을 검증하고 VibeRoom 전용 access/refresh token으로 교환한다.
|
||||
* - 응답에는 accessToken, refreshToken, user를 포함한다.
|
||||
* - user는 최소 id, name, email, grade를 포함해 로그인 직후 헤더/프로필 UI를 채울 수 있게 한다.
|
||||
*/
|
||||
loginWithSocial: async (data: SocialLoginRequest): Promise<AuthResponse> => {
|
||||
return apiClient<AuthResponse>('api/v1/auth/social', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 현재 액세스 토큰의 사용자 정보를 반환한다.
|
||||
* - 응답에는 최소 id, name, email, grade를 포함한다.
|
||||
* - 인증이 만료되었으면 401을 반환하고, 클라이언트가 refresh 흐름으로 넘어갈 수 있게 한다.
|
||||
*/
|
||||
getMe: async (): Promise<UserMeResponse> => {
|
||||
return apiClient<UserMeResponse>('api/v1/auth/me', {
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - refresh token을 받아 새 access/refresh token 쌍을 재발급한다.
|
||||
* - refresh token이 유효하지 않으면 401을 반환한다.
|
||||
* - 클라이언트는 이 응답으로 쿠키와 전역 인증 상태를 갱신한다.
|
||||
*/
|
||||
refreshToken: async (refreshToken: string): Promise<RefreshTokenResponse> => {
|
||||
return apiClient<RefreshTokenResponse>('api/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 현재 로그인 세션을 서버에서 종료한다.
|
||||
* - 토큰 블랙리스트 또는 세션 무효화 정책이 있다면 여기서 처리한다.
|
||||
* - 이미 만료된 세션이어도 멱등적으로 204 또는 성공 응답을 반환한다.
|
||||
*/
|
||||
logout: async (): Promise<void> => {
|
||||
return apiClient<void>('api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
expectNoContent: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
38
src/features/auth/components/AuthLandingLoginButton.tsx
Normal file
38
src/features/auth/components/AuthLandingLoginButton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAuthStore } from '@/entities/auth';
|
||||
import { Button, type ButtonSize, type ButtonVariant } from '@/shared/ui/Button';
|
||||
import { hasClientAuth } from '../model/hasClientAuth';
|
||||
|
||||
interface AuthLandingLoginButtonProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
export function AuthLandingLoginButton({
|
||||
children,
|
||||
className,
|
||||
size = 'sm',
|
||||
variant = 'ghost',
|
||||
}: AuthLandingLoginButtonProps) {
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
|
||||
if (
|
||||
hasClientAuth({
|
||||
accessToken,
|
||||
isAuthenticated,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button href="/login" variant={variant} size={size} className={className}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
52
src/features/auth/components/AuthRedirectButton.tsx
Normal file
52
src/features/auth/components/AuthRedirectButton.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAuthStore } from '@/entities/auth';
|
||||
import { Button, type ButtonSize, type ButtonVariant } from '@/shared/ui/Button';
|
||||
import { hasClientAuth } from '../model/hasClientAuth';
|
||||
|
||||
interface AuthRedirectButtonProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
authenticatedHref?: string;
|
||||
unauthenticatedHref?: string;
|
||||
}
|
||||
|
||||
export function AuthRedirectButton({
|
||||
children,
|
||||
className,
|
||||
size = 'md',
|
||||
variant = 'primary',
|
||||
authenticatedHref = '/app',
|
||||
unauthenticatedHref = '/login',
|
||||
}: AuthRedirectButtonProps) {
|
||||
const router = useRouter();
|
||||
const accessToken = useAuthStore((state) => state.accessToken);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(
|
||||
hasClientAuth({
|
||||
accessToken,
|
||||
isAuthenticated,
|
||||
})
|
||||
? authenticatedHref
|
||||
: unauthenticatedHref,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { useSocialLogin } from "../hooks/useSocialLogin";
|
||||
|
||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
const FACEBOOK_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "";
|
||||
|
||||
/**
|
||||
* 실제 버튼들을 렌더링하고 커스텀 훅(useSocialLogin)을 호출하는 내부 컴포넌트입니다.
|
||||
@@ -13,8 +13,6 @@ const FACEBOOK_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID || "";
|
||||
const SocialLoginButtons = () => {
|
||||
const {
|
||||
loginWithGoogle,
|
||||
loginWithApple,
|
||||
handleFacebookCallback,
|
||||
isLoading,
|
||||
error,
|
||||
} = useSocialLogin();
|
||||
@@ -49,7 +47,7 @@ const SocialLoginButtons = () => {
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
{isLoading ? "연결 중..." : "Google로 계속하기"}
|
||||
{isLoading ? copy.auth.social.connecting : copy.auth.social.continueWithGoogle}
|
||||
</button>
|
||||
|
||||
{/* 2. Apple 로그인 (react-apple-signin-auth 연동) */}
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { useAuthStore } from "@/store/useAuthStore";
|
||||
import { useGoogleLogin } from "@react-oauth/google";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import appleAuthHelpers from "react-apple-signin-auth";
|
||||
import { authApi } from "../api/authApi";
|
||||
import { useGoogleLogin } from '@react-oauth/google';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import appleAuthHelpers from 'react-apple-signin-auth';
|
||||
import { useAuthStore } from '@/entities/auth';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { authApi } from '../api/authApi';
|
||||
|
||||
interface AppleSignInResponse {
|
||||
authorization?: {
|
||||
id_token?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AppleSignInError {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AppleAuthHelperBridge {
|
||||
signIn: (options: {
|
||||
authOptions: {
|
||||
clientId: string;
|
||||
scope: string;
|
||||
redirectURI: string;
|
||||
usePopup: boolean;
|
||||
};
|
||||
onSuccess: (response: AppleSignInResponse) => void;
|
||||
onError: (err: AppleSignInError) => void;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
interface FacebookLoginResponse {
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export const useSocialLogin = () => {
|
||||
const router = useRouter();
|
||||
@@ -15,30 +43,23 @@ export const useSocialLogin = () => {
|
||||
* 플랫폼별 SDK에서 획득한 토큰을 백엔드로 보내어 VibeRoom 전용 JWT로 교환합니다.
|
||||
*/
|
||||
const handleSocialLogin = async (
|
||||
provider: "google" | "apple" | "facebook",
|
||||
provider: 'google' | 'apple' | 'facebook',
|
||||
socialToken: string,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log(`[${provider}] token:`, socialToken);
|
||||
try {
|
||||
// 1. 백엔드로 소셜 토큰 전송 (토큰 교환)
|
||||
const response = await authApi.loginWithSocial({
|
||||
provider,
|
||||
token: socialToken,
|
||||
});
|
||||
|
||||
console.log(`[${provider}] 백엔드 연동 성공! JWT:`, response.accessToken);
|
||||
|
||||
// 2. 응답받은 VibeRoom 전용 토큰과 유저 정보를 전역 상태 및 쿠키에 저장
|
||||
useAuthStore.getState().setAuth(response);
|
||||
|
||||
// 3. 메인 허브 화면으로 이동
|
||||
router.push("/app");
|
||||
router.push('/app');
|
||||
} catch (err) {
|
||||
console.error(`[${provider}] 로그인 실패:`, err);
|
||||
setError("로그인에 실패했습니다. 다시 시도해 주세요.");
|
||||
setError(copy.auth.errors.loginFailed);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -50,11 +71,10 @@ export const useSocialLogin = () => {
|
||||
*/
|
||||
const loginWithGoogle = useGoogleLogin({
|
||||
onSuccess: (tokenResponse) => {
|
||||
console.log(tokenResponse);
|
||||
handleSocialLogin("google", tokenResponse.access_token);
|
||||
handleSocialLogin('google', tokenResponse.access_token);
|
||||
},
|
||||
onError: () => {
|
||||
setError("구글 로그인에 실패했습니다. 팝업 차단 여부를 확인해 주세요.");
|
||||
setError(copy.auth.errors.googleFailed);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,39 +84,28 @@ export const useSocialLogin = () => {
|
||||
*/
|
||||
const loginWithApple = () => {
|
||||
try {
|
||||
const appleHelperBridge = appleAuthHelpers as unknown as {
|
||||
signIn: (options: {
|
||||
authOptions: {
|
||||
clientId: string;
|
||||
scope: string;
|
||||
redirectURI: string;
|
||||
usePopup: boolean;
|
||||
};
|
||||
onSuccess: (response: any) => void;
|
||||
onError: (err: any) => void;
|
||||
}) => void;
|
||||
};
|
||||
const appleHelperBridge = appleAuthHelpers as unknown as AppleAuthHelperBridge;
|
||||
|
||||
appleHelperBridge.signIn({
|
||||
authOptions: {
|
||||
clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID || "",
|
||||
scope: "email name",
|
||||
clientId: process.env.NEXT_PUBLIC_APPLE_CLIENT_ID || '',
|
||||
scope: 'email name',
|
||||
redirectURI: window.location.origin, // Apple 요구사항: 현재 도메인 입력 필요
|
||||
usePopup: true,
|
||||
},
|
||||
onSuccess: (response: any) => {
|
||||
onSuccess: (response: AppleSignInResponse) => {
|
||||
if (response.authorization?.id_token) {
|
||||
handleSocialLogin("apple", response.authorization.id_token);
|
||||
handleSocialLogin('apple', response.authorization.id_token);
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.error("Apple SignIn error:", err);
|
||||
setError("애플 로그인 중 오류가 발생했습니다.");
|
||||
onError: (err: AppleSignInError) => {
|
||||
console.error('Apple SignIn error:', err);
|
||||
setError(copy.auth.errors.appleFailed);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("애플 로그인 초기화 실패");
|
||||
setError(copy.auth.errors.appleInitFailed);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,11 +113,11 @@ export const useSocialLogin = () => {
|
||||
* [비즈니스 로직 4] Facebook Callback
|
||||
* react-facebook-login 컴포넌트에서 콜백으로 받은 토큰을 처리합니다.
|
||||
*/
|
||||
const handleFacebookCallback = (response: any) => {
|
||||
const handleFacebookCallback = (response: FacebookLoginResponse) => {
|
||||
if (response?.accessToken) {
|
||||
handleSocialLogin("facebook", response.accessToken);
|
||||
handleSocialLogin('facebook', response.accessToken);
|
||||
} else {
|
||||
setError("페이스북 로그인에 실패했습니다.");
|
||||
setError(copy.auth.errors.facebookFailed);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
2
src/features/auth/model/constants.ts
Normal file
2
src/features/auth/model/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const TOKEN_COOKIE_KEY = "vr_access_token";
|
||||
export const REFRESH_TOKEN_COOKIE_KEY = "vr_refresh_token";
|
||||
13
src/features/auth/model/hasClientAuth.ts
Normal file
13
src/features/auth/model/hasClientAuth.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_KEY } from '@/shared/config/authTokens';
|
||||
|
||||
interface HasClientAuthInput {
|
||||
accessToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export const hasClientAuth = ({ accessToken, isAuthenticated }: HasClientAuthInput) => {
|
||||
return isAuthenticated || Boolean(accessToken) || Boolean(Cookies.get(TOKEN_COOKIE_KEY));
|
||||
};
|
||||
@@ -1,17 +1,6 @@
|
||||
export interface SocialLoginRequest {
|
||||
provider: "google" | "apple" | "facebook";
|
||||
token: string; // 소셜 프로바이더로부터 발급받은 id_token 또는 access_token
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken: string; // VibeRoom 전용 JWT (API 요청 시 사용)
|
||||
refreshToken: string; // 토큰 갱신용
|
||||
user?: UserMeResponse; // 선택적으로 유저 정보를 포함할 수 있음
|
||||
}
|
||||
|
||||
export interface UserMeResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
grade: string;
|
||||
}
|
||||
export type {
|
||||
AuthResponse,
|
||||
PasswordLoginRequest,
|
||||
SocialLoginRequest,
|
||||
UserMeResponse,
|
||||
} from '@/entities/auth';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './ui/ExitHoldButton';
|
||||
@@ -1,141 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const HOLD_DURATION_MS = 1000;
|
||||
const BOOST_DURATION_MS = 50;
|
||||
const COMPLETE_HOLD_MS = 160;
|
||||
|
||||
const mapProgress = (elapsedMs: number) => {
|
||||
if (elapsedMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (elapsedMs <= BOOST_DURATION_MS) {
|
||||
return 0.2 * (elapsedMs / BOOST_DURATION_MS);
|
||||
}
|
||||
|
||||
const tailElapsedMs = Math.min(elapsedMs - BOOST_DURATION_MS, HOLD_DURATION_MS - BOOST_DURATION_MS);
|
||||
return 0.2 + 0.8 * (tailElapsedMs / (HOLD_DURATION_MS - BOOST_DURATION_MS));
|
||||
};
|
||||
|
||||
export const useHoldToConfirm = (onConfirm: () => void) => {
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const confirmTimeoutRef = useRef<number | null>(null);
|
||||
const completeTimeoutRef = useRef<number | null>(null);
|
||||
const startRef = useRef<number | null>(null);
|
||||
const confirmedRef = useRef(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [isHolding, setHolding] = useState(false);
|
||||
const [isCompleted, setCompleted] = useState(false);
|
||||
|
||||
const clearFrame = () => {
|
||||
if (frameRef.current !== null) {
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearTimers = () => {
|
||||
if (confirmTimeoutRef.current !== null) {
|
||||
window.clearTimeout(confirmTimeoutRef.current);
|
||||
confirmTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (completeTimeoutRef.current !== null) {
|
||||
window.clearTimeout(completeTimeoutRef.current);
|
||||
completeTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = (withCompleted = false) => {
|
||||
clearFrame();
|
||||
clearTimers();
|
||||
startRef.current = null;
|
||||
confirmedRef.current = false;
|
||||
setHolding(false);
|
||||
setCompleted(withCompleted);
|
||||
setProgress(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearFrame();
|
||||
clearTimers();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const step = () => {
|
||||
if (startRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMs = performance.now() - startRef.current;
|
||||
const nextProgress = mapProgress(elapsedMs);
|
||||
const clampedProgress = Math.min(nextProgress, 1);
|
||||
setProgress(clampedProgress);
|
||||
|
||||
if (clampedProgress >= 1 && !confirmedRef.current) {
|
||||
confirmedRef.current = true;
|
||||
if (confirmTimeoutRef.current !== null) {
|
||||
window.clearTimeout(confirmTimeoutRef.current);
|
||||
confirmTimeoutRef.current = null;
|
||||
}
|
||||
setHolding(false);
|
||||
setCompleted(true);
|
||||
onConfirm();
|
||||
|
||||
completeTimeoutRef.current = window.setTimeout(() => {
|
||||
reset(false);
|
||||
}, COMPLETE_HOLD_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
frameRef.current = window.requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (isHolding || isCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
clearFrame();
|
||||
confirmedRef.current = false;
|
||||
setCompleted(false);
|
||||
setProgress(0);
|
||||
setHolding(true);
|
||||
startRef.current = performance.now();
|
||||
frameRef.current = window.requestAnimationFrame(step);
|
||||
confirmTimeoutRef.current = window.setTimeout(() => {
|
||||
if (!confirmedRef.current) {
|
||||
confirmedRef.current = true;
|
||||
confirmTimeoutRef.current = null;
|
||||
setProgress(1);
|
||||
setHolding(false);
|
||||
setCompleted(true);
|
||||
onConfirm();
|
||||
|
||||
completeTimeoutRef.current = window.setTimeout(() => {
|
||||
reset(false);
|
||||
}, COMPLETE_HOLD_MS);
|
||||
}
|
||||
}, HOLD_DURATION_MS);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (!isHolding) {
|
||||
return;
|
||||
}
|
||||
|
||||
reset();
|
||||
};
|
||||
|
||||
return {
|
||||
progress,
|
||||
isHolding,
|
||||
isCompleted,
|
||||
start,
|
||||
cancel,
|
||||
};
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useHoldToConfirm } from '../model/useHoldToConfirm';
|
||||
|
||||
interface ExitHoldButtonProps {
|
||||
variant: 'bar' | 'ring';
|
||||
onConfirm: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RING_RADIUS = 13;
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
|
||||
|
||||
export const ExitHoldButton = ({
|
||||
variant,
|
||||
onConfirm,
|
||||
className,
|
||||
}: ExitHoldButtonProps) => {
|
||||
const { progress, isHolding, isCompleted, start, cancel } = useHoldToConfirm(onConfirm);
|
||||
const ringOffset = RING_CIRCUMFERENCE * (1 - progress);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
start();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (variant === 'ring') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="길게 눌러 나가기"
|
||||
onMouseDown={start}
|
||||
onMouseUp={cancel}
|
||||
onMouseLeave={cancel}
|
||||
onTouchStart={start}
|
||||
onTouchEnd={cancel}
|
||||
onTouchCancel={cancel}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
className={cn(
|
||||
'relative inline-flex h-9 w-9 items-center justify-center rounded-full bg-white/8 text-white/74 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80',
|
||||
isHolding && 'bg-white/16',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden
|
||||
className="-rotate-90 absolute inset-0 h-9 w-9"
|
||||
viewBox="0 0 32 32"
|
||||
>
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r={RING_RADIUS}
|
||||
fill="none"
|
||||
stroke="rgba(186,230,253,0.92)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={RING_CIRCUMFERENCE}
|
||||
strokeDashoffset={ringOffset}
|
||||
/>
|
||||
</svg>
|
||||
<span aria-hidden className="relative z-10 text-[12px]">
|
||||
⤫
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="길게 눌러 나가기"
|
||||
onMouseDown={start}
|
||||
onMouseUp={cancel}
|
||||
onMouseLeave={cancel}
|
||||
onTouchStart={start}
|
||||
onTouchEnd={cancel}
|
||||
onTouchCancel={cancel}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-lg bg-white/8 px-2.5 py-1.5 text-xs text-white/82 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isHolding || isCompleted ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute inset-0 z-0 origin-left transform-gpu bg-sky-200/24 rounded-none',
|
||||
isHolding && 'animate-[exit-hold-bar-fill_1000ms_linear_forwards]',
|
||||
)}
|
||||
style={isCompleted ? { transform: 'scaleX(1)' } : undefined}
|
||||
/>
|
||||
) : null}
|
||||
<span className="relative z-10 inline-flex items-center gap-1">
|
||||
<span aria-hidden>⤫</span>
|
||||
<span>나가기</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
292
src/features/focus-session/api/focusSessionApi.ts
Normal file
292
src/features/focus-session/api/focusSessionApi.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { normalizeFocusPlanToday, type FocusPlanToday } from '@/entities/focus-plan';
|
||||
import { apiClient } from '@/shared/lib/apiClient';
|
||||
|
||||
export type FocusSessionPhase = 'focus' | 'break';
|
||||
export type FocusSessionState = 'running' | 'paused';
|
||||
export type FocusSessionCompletionType = 'goal-complete' | 'timer-complete' | 'manual-end';
|
||||
|
||||
interface RawFocusSession {
|
||||
id: number | string;
|
||||
sceneId: string;
|
||||
goal: string;
|
||||
atmosphereId?: string | null;
|
||||
soundPresetId: string | null;
|
||||
focusPlanItemId?: number | null;
|
||||
microStep?: string | null;
|
||||
phase: FocusSessionPhase;
|
||||
state: FocusSessionState;
|
||||
phaseStartedAt: string;
|
||||
phaseEndsAt: string | null;
|
||||
phaseRemainingSeconds: number;
|
||||
focusDurationSeconds: number;
|
||||
breakDurationSeconds: number;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
abandonedAt: string | null;
|
||||
serverNow: string;
|
||||
}
|
||||
|
||||
interface RawAdvanceCurrentGoalResponse {
|
||||
nextSession: RawFocusSession;
|
||||
updatedPlanToday: Parameters<typeof normalizeFocusPlanToday>[0];
|
||||
}
|
||||
|
||||
interface RawCurrentSessionThought {
|
||||
id: string;
|
||||
text: string;
|
||||
sceneName: string;
|
||||
isCompleted: boolean;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
interface RawCompletionResult {
|
||||
completedSessionId: string;
|
||||
completionSource: 'timer-complete' | 'manual-end' | 'goal-complete';
|
||||
goalText: string;
|
||||
focusedSeconds: number;
|
||||
thoughts: RawCurrentSessionThought[];
|
||||
}
|
||||
|
||||
export interface FocusSession {
|
||||
id: string;
|
||||
sceneId: string;
|
||||
goal: string;
|
||||
atmosphereId?: string | null;
|
||||
soundPresetId: string | null;
|
||||
focusPlanItemId?: string | null;
|
||||
microStep?: string | null;
|
||||
phase: FocusSessionPhase;
|
||||
state: FocusSessionState;
|
||||
phaseStartedAt: string;
|
||||
phaseEndsAt: string | null;
|
||||
phaseRemainingSeconds: number;
|
||||
focusDurationSeconds: number;
|
||||
breakDurationSeconds: number;
|
||||
startedAt: string;
|
||||
completedAt: string | null;
|
||||
abandonedAt: string | null;
|
||||
serverNow: string;
|
||||
}
|
||||
|
||||
export interface CurrentSessionThought {
|
||||
id: string;
|
||||
text: string;
|
||||
sceneName: string;
|
||||
isCompleted: boolean;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
export interface CompletionResult {
|
||||
completedSessionId: string;
|
||||
completionSource: 'timer-complete' | 'manual-end' | 'goal-complete';
|
||||
goalText: string;
|
||||
focusedSeconds: number;
|
||||
thoughts: CurrentSessionThought[];
|
||||
}
|
||||
|
||||
export interface StartFocusSessionRequest {
|
||||
sceneId: string;
|
||||
goal: string;
|
||||
focusDurationMinutes: number;
|
||||
atmosphereId: string;
|
||||
soundPresetId?: string | null;
|
||||
focusPlanItemId?: string;
|
||||
microStep?: string | null;
|
||||
entryPoint?: 'space-setup' | 'goal-complete';
|
||||
}
|
||||
|
||||
export interface CompleteFocusSessionRequest {
|
||||
completionType: FocusSessionCompletionType;
|
||||
completedGoal?: string;
|
||||
focusScore?: number;
|
||||
distractionCount?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCurrentFocusSessionSelectionRequest {
|
||||
sceneId?: string;
|
||||
atmosphereId?: string | null;
|
||||
soundPresetId?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateCurrentFocusSessionIntentRequest {
|
||||
goal?: string;
|
||||
microStep?: string | null;
|
||||
}
|
||||
|
||||
export interface ExtendCurrentPhaseRequest {
|
||||
additionalMinutes: number;
|
||||
}
|
||||
|
||||
export interface AdvanceCurrentGoalRequest {
|
||||
completedGoal: string;
|
||||
nextGoal: string;
|
||||
sceneId?: string;
|
||||
focusDurationMinutes?: number;
|
||||
atmosphereId?: string | null;
|
||||
soundPresetId?: string | null;
|
||||
focusPlanItemId?: string;
|
||||
}
|
||||
|
||||
export interface AdvanceCurrentGoalResponse {
|
||||
nextSession: FocusSession;
|
||||
updatedPlanToday: FocusPlanToday;
|
||||
}
|
||||
|
||||
const toNumericId = (value: string | undefined) => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const normalizeFocusSession = (session: RawFocusSession): FocusSession => {
|
||||
return {
|
||||
...session,
|
||||
id: String(session.id),
|
||||
focusPlanItemId: session.focusPlanItemId == null ? null : String(session.focusPlanItemId),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAdvanceGoalResponse = (
|
||||
response: RawAdvanceCurrentGoalResponse,
|
||||
): AdvanceCurrentGoalResponse => {
|
||||
return {
|
||||
nextSession: normalizeFocusSession(response.nextSession),
|
||||
updatedPlanToday: normalizeFocusPlanToday(response.updatedPlanToday),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeCurrentSessionThought = (
|
||||
thought: RawCurrentSessionThought,
|
||||
): CurrentSessionThought => {
|
||||
return {
|
||||
...thought,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeCompletionResult = (
|
||||
result: RawCompletionResult,
|
||||
): CompletionResult => {
|
||||
return {
|
||||
...result,
|
||||
thoughts: result.thoughts.map(normalizeCurrentSessionThought),
|
||||
};
|
||||
};
|
||||
|
||||
export const focusSessionApi = {
|
||||
getCurrentSession: async (): Promise<FocusSession | null> => {
|
||||
const response = await apiClient<RawFocusSession | null>('api/v1/focus-sessions/current', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return response ? normalizeFocusSession(response) : null;
|
||||
},
|
||||
|
||||
getCurrentSessionThoughts: async (): Promise<CurrentSessionThought[]> => {
|
||||
const response = await apiClient<RawCurrentSessionThought[]>('api/v1/focus-sessions/current/thoughts', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return response.map(normalizeCurrentSessionThought);
|
||||
},
|
||||
|
||||
startSession: async (payload: StartFocusSessionRequest): Promise<FocusSession> => {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
focusPlanItemId: toNumericId(payload.focusPlanItemId),
|
||||
}),
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
pauseSession: async (): Promise<FocusSession> => {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/pause', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
resumeSession: async (): Promise<FocusSession> => {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/resume', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
restartCurrentPhase: async (): Promise<FocusSession> => {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/restart-phase', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
extendCurrentPhase: async (payload: ExtendCurrentPhaseRequest): Promise<FocusSession> => {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/extend-phase', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
updateCurrentSelection: async (
|
||||
payload: UpdateCurrentFocusSessionSelectionRequest,
|
||||
): Promise<FocusSession> => {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/selection', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
updateCurrentIntent: async (
|
||||
payload: UpdateCurrentFocusSessionIntentRequest,
|
||||
): Promise<FocusSession> => {
|
||||
const response = await apiClient<RawFocusSession>('api/v1/focus-sessions/current/intent', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return normalizeFocusSession(response);
|
||||
},
|
||||
|
||||
completeSession: async (payload: CompleteFocusSessionRequest): Promise<CompletionResult> => {
|
||||
const response = await apiClient<RawCompletionResult>('api/v1/focus-sessions/current/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return normalizeCompletionResult(response);
|
||||
},
|
||||
|
||||
advanceGoal: async (payload: AdvanceCurrentGoalRequest): Promise<AdvanceCurrentGoalResponse> => {
|
||||
const response = await apiClient<RawAdvanceCurrentGoalResponse>(
|
||||
'api/v1/focus-sessions/current/advance-goal',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
focusPlanItemId: toNumericId(payload.focusPlanItemId),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return normalizeAdvanceGoalResponse(response);
|
||||
},
|
||||
|
||||
abandonSession: async (): Promise<void> => {
|
||||
return apiClient<void>('api/v1/focus-sessions/current/abandon', {
|
||||
method: 'POST',
|
||||
expectNoContent: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
2
src/features/focus-session/index.ts
Normal file
2
src/features/focus-session/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './api/focusSessionApi';
|
||||
export * from './model/useFocusSessionEngine';
|
||||
330
src/features/focus-session/model/useFocusSessionEngine.ts
Normal file
330
src/features/focus-session/model/useFocusSessionEngine.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import {
|
||||
type AdvanceCurrentGoalRequest,
|
||||
type AdvanceCurrentGoalResponse,
|
||||
type CompletionResult,
|
||||
focusSessionApi,
|
||||
type CompleteFocusSessionRequest,
|
||||
type FocusSession,
|
||||
type FocusSessionPhase,
|
||||
type FocusSessionState,
|
||||
type StartFocusSessionRequest,
|
||||
type UpdateCurrentFocusSessionIntentRequest,
|
||||
type UpdateCurrentFocusSessionSelectionRequest,
|
||||
} from '../api/focusSessionApi';
|
||||
|
||||
const SESSION_SYNC_INTERVAL_MS = 30_000;
|
||||
const TIMER_TICK_INTERVAL_MS = 1_000;
|
||||
|
||||
const padClock = (value: number) => String(value).padStart(2, '0');
|
||||
|
||||
export const formatDurationClock = (totalSeconds: number) => {
|
||||
const safeSeconds = Math.max(0, totalSeconds);
|
||||
const minutes = Math.floor(safeSeconds / 60);
|
||||
const seconds = safeSeconds % 60;
|
||||
return `${padClock(minutes)}:${padClock(seconds)}`;
|
||||
};
|
||||
|
||||
const getServerOffsetMs = (session: FocusSession) => {
|
||||
const serverNowMs = Date.parse(session.serverNow);
|
||||
|
||||
if (Number.isNaN(serverNowMs)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return serverNowMs - Date.now();
|
||||
};
|
||||
|
||||
const getRemainingSeconds = (
|
||||
session: FocusSession | null,
|
||||
serverOffsetMs: number,
|
||||
tickMs: number,
|
||||
) => {
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.state === 'paused' || !session.phaseEndsAt) {
|
||||
return Math.max(0, session.phaseRemainingSeconds);
|
||||
}
|
||||
|
||||
const phaseEndMs = Date.parse(session.phaseEndsAt);
|
||||
|
||||
if (Number.isNaN(phaseEndMs)) {
|
||||
return Math.max(0, session.phaseRemainingSeconds);
|
||||
}
|
||||
|
||||
return Math.max(0, Math.ceil((phaseEndMs - (tickMs + serverOffsetMs)) / 1000));
|
||||
};
|
||||
|
||||
interface UseFocusSessionEngineResult {
|
||||
currentSession: FocusSession | null;
|
||||
isBootstrapping: boolean;
|
||||
isMutating: boolean;
|
||||
error: string | null;
|
||||
remainingSeconds: number | null;
|
||||
timeDisplay: string | null;
|
||||
playbackState: FocusSessionState | null;
|
||||
phase: FocusSessionPhase | null;
|
||||
syncCurrentSession: () => Promise<FocusSession | null>;
|
||||
startSession: (payload: StartFocusSessionRequest) => Promise<FocusSession | null>;
|
||||
pauseSession: () => Promise<FocusSession | null>;
|
||||
resumeSession: () => Promise<FocusSession | null>;
|
||||
restartCurrentPhase: () => Promise<FocusSession | null>;
|
||||
extendCurrentPhase: (payload: { additionalMinutes: number }) => Promise<FocusSession | null>;
|
||||
updateCurrentIntent: (payload: UpdateCurrentFocusSessionIntentRequest) => Promise<FocusSession | null>;
|
||||
updateCurrentSelection: (payload: UpdateCurrentFocusSessionSelectionRequest) => Promise<FocusSession | null>;
|
||||
completeSession: (payload: CompleteFocusSessionRequest) => Promise<CompletionResult | null>;
|
||||
advanceGoal: (payload: AdvanceCurrentGoalRequest) => Promise<AdvanceCurrentGoalResponse | null>;
|
||||
abandonSession: () => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useFocusSessionEngine = (): UseFocusSessionEngineResult => {
|
||||
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
||||
const [isBootstrapping, setBootstrapping] = useState(true);
|
||||
const [isMutating, setMutating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [serverOffsetMs, setServerOffsetMs] = useState(0);
|
||||
const [tickMs, setTickMs] = useState(Date.now());
|
||||
const didHydrateRef = useRef(false);
|
||||
|
||||
const applySession = useCallback((session: FocusSession | null) => {
|
||||
setCurrentSession(session);
|
||||
setServerOffsetMs(session ? getServerOffsetMs(session) : 0);
|
||||
setTickMs(Date.now());
|
||||
return session;
|
||||
}, []);
|
||||
|
||||
const syncCurrentSession = useCallback(async () => {
|
||||
try {
|
||||
const session = await focusSessionApi.getCurrentSession();
|
||||
setError(null);
|
||||
return applySession(session);
|
||||
} catch (nextError) {
|
||||
const message =
|
||||
nextError instanceof Error ? nextError.message : copy.focusSession.syncFailed;
|
||||
setError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setBootstrapping(false);
|
||||
}
|
||||
}, [applySession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (didHydrateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
didHydrateRef.current = true;
|
||||
void syncCurrentSession();
|
||||
}, [syncCurrentSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSession || currentSession.state !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void syncCurrentSession();
|
||||
}, SESSION_SYNC_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentSession, syncCurrentSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSession || currentSession.state !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setTickMs(Date.now());
|
||||
}, TIMER_TICK_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentSession]);
|
||||
|
||||
const remainingSeconds = useMemo(() => {
|
||||
return getRemainingSeconds(currentSession, serverOffsetMs, tickMs);
|
||||
}, [currentSession, serverOffsetMs, tickMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSession || currentSession.state !== 'running' || remainingSeconds !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void syncCurrentSession();
|
||||
}, 1_200);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [currentSession, remainingSeconds, syncCurrentSession]);
|
||||
|
||||
const runMutation = async <T>(task: () => Promise<T>, fallbackMessage: string) => {
|
||||
setMutating(true);
|
||||
|
||||
try {
|
||||
const result = await task();
|
||||
setError(null);
|
||||
return result;
|
||||
} catch (nextError) {
|
||||
const message = nextError instanceof Error ? nextError.message : fallbackMessage;
|
||||
setError(message);
|
||||
return null;
|
||||
} finally {
|
||||
setMutating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
currentSession,
|
||||
isBootstrapping,
|
||||
isMutating,
|
||||
error,
|
||||
remainingSeconds,
|
||||
timeDisplay: remainingSeconds === null ? null : formatDurationClock(remainingSeconds),
|
||||
playbackState: currentSession?.state ?? null,
|
||||
phase: currentSession?.phase ?? null,
|
||||
syncCurrentSession,
|
||||
startSession: async (payload) => {
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.startSession(payload),
|
||||
copy.focusSession.startFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
},
|
||||
pauseSession: async () => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.pauseSession(),
|
||||
copy.focusSession.pauseFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
},
|
||||
resumeSession: async () => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.resumeSession(),
|
||||
copy.focusSession.resumeFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
},
|
||||
restartCurrentPhase: async () => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.restartCurrentPhase(),
|
||||
copy.focusSession.restartPhaseFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
},
|
||||
extendCurrentPhase: async (payload) => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.extendCurrentPhase(payload),
|
||||
copy.focusSession.resumeFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
},
|
||||
updateCurrentIntent: async (payload) => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.updateCurrentIntent(payload),
|
||||
copy.focusSession.intentUpdateFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
},
|
||||
updateCurrentSelection: async (payload) => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await runMutation(
|
||||
() => focusSessionApi.updateCurrentSelection(payload),
|
||||
copy.focusSession.syncFailed,
|
||||
);
|
||||
|
||||
return applySession(session);
|
||||
},
|
||||
completeSession: async (payload) => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await runMutation(
|
||||
() => focusSessionApi.completeSession(payload),
|
||||
copy.focusSession.completeFailed,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
applySession(null);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
advanceGoal: async (payload) => {
|
||||
if (!currentSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await runMutation(
|
||||
() => focusSessionApi.advanceGoal(payload),
|
||||
copy.focusSession.completeFailed,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
applySession(result.nextSession);
|
||||
return result;
|
||||
},
|
||||
abandonSession: async () => {
|
||||
if (!currentSession) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = await runMutation(
|
||||
() => focusSessionApi.abandonSession(),
|
||||
copy.focusSession.abandonFailed,
|
||||
);
|
||||
|
||||
if (result === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
applySession(null);
|
||||
return true;
|
||||
},
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RecentThought } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface InboxListProps {
|
||||
@@ -17,7 +18,7 @@ export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, classN
|
||||
className,
|
||||
)}
|
||||
>
|
||||
지금은 비어 있어요. 집중 중 떠오른 생각을 여기로 주차할 수 있어요.
|
||||
{copy.space.inbox.empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +39,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
|
||||
@@ -51,14 +52,14 @@ export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, classN
|
||||
: 'border-white/20 bg-white/8 text-white/76 hover:bg-white/14',
|
||||
)}
|
||||
>
|
||||
{thought.isCompleted ? '완료됨' : '완료'}
|
||||
{thought.isCompleted ? copy.space.inbox.completed : copy.space.inbox.complete}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteThought(thought)}
|
||||
className="inline-flex h-6 items-center rounded-full border border-rose-200/30 bg-rose-200/10 px-2 text-[10px] text-rose-100/88 transition-colors hover:bg-rose-200/18"
|
||||
>
|
||||
삭제
|
||||
{copy.space.inbox.delete}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
interface ManagePlanSheetContentProps {
|
||||
onClose: () => void;
|
||||
onManage: () => void;
|
||||
@@ -12,8 +14,8 @@ export const ManagePlanSheetContent = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-white">PRO 관리</h3>
|
||||
<p className="text-xs text-white/62">결제/복원은 더미 동작이며 실제 연동은 하지 않아요.</p>
|
||||
<h3 className="text-lg font-semibold tracking-tight text-white">{copy.space.paywall.manageTitle}</h3>
|
||||
<p className="text-xs text-white/62">{copy.space.paywall.manageDescription}</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -22,14 +24,14 @@ export const ManagePlanSheetContent = ({
|
||||
onClick={onManage}
|
||||
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
|
||||
>
|
||||
구독 관리 열기
|
||||
{copy.space.paywall.openSubscription}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
|
||||
>
|
||||
구매 복원
|
||||
{copy.space.paywall.restorePurchase}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +41,7 @@ export const ManagePlanSheetContent = ({
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
||||
>
|
||||
닫기
|
||||
{copy.common.close}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
interface PaywallSheetContentProps {
|
||||
onStartPro: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const VALUE_POINTS = [
|
||||
'프리미엄 Scene Packs',
|
||||
'확장 Sound Packs',
|
||||
'프로필 저장 / 불러오기',
|
||||
];
|
||||
const VALUE_POINTS = copy.space.paywall.points;
|
||||
|
||||
export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContentProps) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-white">PRO에서 더 많은 공간과 사운드를 열어둘 수 있어요.</h3>
|
||||
<p className="text-xs text-white/62">잠금 항목을 누른 순간에만 열리는 더미 결제 시트입니다.</p>
|
||||
<h3 className="text-lg font-semibold tracking-tight text-white">{copy.space.paywall.title}</h3>
|
||||
<p className="text-xs text-white/62">{copy.space.paywall.description}</p>
|
||||
</header>
|
||||
|
||||
<ul className="space-y-2">
|
||||
@@ -33,14 +31,14 @@ export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContent
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
||||
>
|
||||
나중에
|
||||
{copy.space.paywall.later}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartPro}
|
||||
className="rounded-full border border-sky-200/44 bg-sky-300/84 px-3.5 py-1.5 text-xs font-semibold text-slate-900 transition-colors hover:bg-sky-300"
|
||||
>
|
||||
PRO 시작하기
|
||||
{copy.space.paywall.startPro}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PlanTier } from '@/entities/plan';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface PlanPillProps {
|
||||
@@ -16,11 +17,11 @@ export const PlanPill = ({ plan, onClick }: PlanPillProps) => {
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium tracking-[0.08em] uppercase transition-colors',
|
||||
isPro
|
||||
? 'border-amber-200/46 bg-amber-200/14 text-amber-100 hover:bg-amber-200/24'
|
||||
: 'border-white/20 bg-white/8 text-white/82 hover:bg-white/14',
|
||||
? 'border-amber-300/30 bg-amber-100/88 text-amber-950 hover:bg-amber-100'
|
||||
: 'border-brand-dark/12 bg-white/88 text-brand-dark/72 hover:bg-white',
|
||||
)}
|
||||
>
|
||||
{isPro ? 'PRO' : 'Normal'}
|
||||
{isPro ? copy.space.toolsDock.planPro : copy.space.toolsDock.planNormal}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
56
src/features/preferences/api/preferencesApi.ts
Normal file
56
src/features/preferences/api/preferencesApi.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
NOTIFICATION_INTENSITY_OPTIONS,
|
||||
} from '@/shared/config/settingsOptions';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { apiClient } from '@/shared/lib/apiClient';
|
||||
|
||||
export type NotificationIntensity = (typeof NOTIFICATION_INTENSITY_OPTIONS)[number];
|
||||
|
||||
export interface UserFocusPreferences {
|
||||
reduceMotion: boolean;
|
||||
notificationIntensity: NotificationIntensity;
|
||||
defaultAtmosphereId?: string | null;
|
||||
defaultDurationMinutes?: number | null;
|
||||
defaultSceneId: string | null;
|
||||
defaultSoundPresetId: string | null;
|
||||
}
|
||||
|
||||
export type UpdateUserFocusPreferencesRequest = Partial<UserFocusPreferences>;
|
||||
|
||||
export const DEFAULT_USER_FOCUS_PREFERENCES: UserFocusPreferences = {
|
||||
reduceMotion: false,
|
||||
notificationIntensity: copy.preferences.defaultNotificationIntensity,
|
||||
defaultAtmosphereId: null,
|
||||
defaultDurationMinutes: null,
|
||||
defaultSceneId: null,
|
||||
defaultSoundPresetId: null,
|
||||
};
|
||||
|
||||
export const preferencesApi = {
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 로그인한 사용자의 집중 관련 개인 설정을 반환한다.
|
||||
* - 최소 reduceMotion, notificationIntensity, defaultAtmosphereId, defaultDurationMinutes를 포함한다.
|
||||
* - 아직 저장된 값이 없으면 서버 기본값을 내려주거나 null 필드 없이 기본 스키마로 응답한다.
|
||||
*/
|
||||
getFocusPreferences: async (): Promise<UserFocusPreferences> => {
|
||||
return apiClient<UserFocusPreferences>('api/v1/users/me/focus-preferences', {
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 사용자의 집중 개인 설정 일부 또는 전체를 patch 방식으로 저장한다.
|
||||
* - 저장 후 최종 스냅샷 전체를 반환해 프론트가 로컬 상태를 서버 기준으로 맞출 수 있게 한다.
|
||||
* - 알 수 없는 필드는 무시하지 말고 400으로 검증 오류를 돌려준다.
|
||||
*/
|
||||
updateFocusPreferences: async (
|
||||
payload: UpdateUserFocusPreferencesRequest,
|
||||
): Promise<UserFocusPreferences> => {
|
||||
return apiClient<UserFocusPreferences>('api/v1/users/me/focus-preferences', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
};
|
||||
2
src/features/preferences/index.ts
Normal file
2
src/features/preferences/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './api/preferencesApi';
|
||||
export * from './model/useUserFocusPreferences';
|
||||
105
src/features/preferences/model/useUserFocusPreferences.ts
Normal file
105
src/features/preferences/model/useUserFocusPreferences.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import {
|
||||
DEFAULT_USER_FOCUS_PREFERENCES,
|
||||
preferencesApi,
|
||||
type UpdateUserFocusPreferencesRequest,
|
||||
type UserFocusPreferences,
|
||||
} from '../api/preferencesApi';
|
||||
|
||||
interface UseUserFocusPreferencesResult {
|
||||
preferences: UserFocusPreferences;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
saveStateLabel: string | null;
|
||||
updatePreferences: (patch: UpdateUserFocusPreferencesRequest) => Promise<void>;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useUserFocusPreferences = (): UseUserFocusPreferencesResult => {
|
||||
const [preferences, setPreferences] = useState<UserFocusPreferences>(DEFAULT_USER_FOCUS_PREFERENCES);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const [isSaving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveStateLabel, setSaveStateLabel] = useState<string | null>(null);
|
||||
const saveStateTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveStateTimerRef.current !== null) {
|
||||
window.clearTimeout(saveStateTimerRef.current);
|
||||
saveStateTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pushSavedLabel = (label: string) => {
|
||||
setSaveStateLabel(label);
|
||||
|
||||
if (saveStateTimerRef.current !== null) {
|
||||
window.clearTimeout(saveStateTimerRef.current);
|
||||
}
|
||||
|
||||
saveStateTimerRef.current = window.setTimeout(() => {
|
||||
setSaveStateLabel(null);
|
||||
saveStateTimerRef.current = null;
|
||||
}, 2200);
|
||||
};
|
||||
|
||||
const refetch = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const nextPreferences = await preferencesApi.getFocusPreferences();
|
||||
setPreferences(nextPreferences);
|
||||
setError(null);
|
||||
} catch (nextError) {
|
||||
const message =
|
||||
nextError instanceof Error ? nextError.message : copy.preferences.loadFailed;
|
||||
setPreferences(DEFAULT_USER_FOCUS_PREFERENCES);
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
}, []);
|
||||
|
||||
const updatePreferences = async (patch: UpdateUserFocusPreferencesRequest) => {
|
||||
const previous = preferences;
|
||||
const optimistic = { ...preferences, ...patch };
|
||||
|
||||
setPreferences(optimistic);
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const persisted = await preferencesApi.updateFocusPreferences(patch);
|
||||
setPreferences(persisted);
|
||||
pushSavedLabel(copy.preferences.saved);
|
||||
} catch (nextError) {
|
||||
const message =
|
||||
nextError instanceof Error ? nextError.message : copy.preferences.saveFailed;
|
||||
setPreferences(previous);
|
||||
setError(message);
|
||||
pushSavedLabel(copy.preferences.saveFailedLabel);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
preferences,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
saveStateLabel,
|
||||
updatePreferences,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
export const RECOVERY_30S_BUTTON_LABEL = '숨 고르기 30초';
|
||||
export const RECOVERY_30S_MODE_LABEL = 'BREATHE';
|
||||
export const RECOVERY_30S_TOAST_MESSAGE = '잠깐 숨 고르고, 다시 천천히 시작해요.';
|
||||
export const RECOVERY_30S_COMPLETE_MESSAGE = '준비됐어요. 집중으로 돌아가요.';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
export const RECOVERY_30S_BUTTON_LABEL = copy.restart30s.button;
|
||||
export const RECOVERY_30S_MODE_LABEL = copy.restart30s.mode;
|
||||
export const RECOVERY_30S_TOAST_MESSAGE = copy.restart30s.toast;
|
||||
export const RECOVERY_30S_COMPLETE_MESSAGE = copy.restart30s.complete;
|
||||
|
||||
1
src/features/scene-select/index.ts
Normal file
1
src/features/scene-select/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/SceneSelectCarousel';
|
||||
70
src/features/scene-select/ui/SceneSelectCarousel.tsx
Normal file
70
src/features/scene-select/ui/SceneSelectCarousel.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { SceneTheme } from '@/entities/scene';
|
||||
import { getSceneCardBackgroundStyle, type SceneAssetMap } from '@/entities/media';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useDragScroll } from '@/shared/lib/useDragScroll';
|
||||
|
||||
interface SceneSelectCarouselProps {
|
||||
scenes: SceneTheme[];
|
||||
selectedSceneId: string;
|
||||
sceneAssetMap?: SceneAssetMap;
|
||||
onSelect: (sceneId: string) => void;
|
||||
}
|
||||
|
||||
export const SceneSelectCarousel = ({
|
||||
scenes,
|
||||
selectedSceneId,
|
||||
sceneAssetMap,
|
||||
onSelect,
|
||||
}: SceneSelectCarouselProps) => {
|
||||
const { containerRef, events, isDragging, shouldSuppressClick } = useDragScroll();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
{...events}
|
||||
className={cn(
|
||||
"-mx-1 overflow-x-auto px-1 pb-1 scrollbar-none",
|
||||
isDragging ? "cursor-grabbing" : "cursor-grab"
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-full gap-2.5">
|
||||
{scenes.map((scene) => {
|
||||
const selected = scene.id === selectedSceneId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={scene.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!shouldSuppressClick) {
|
||||
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',
|
||||
isDragging && 'pointer-events-none'
|
||||
)}
|
||||
style={getSceneCardBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
|
||||
aria-label={`${scene.name} ${copy.common.select}`}
|
||||
>
|
||||
<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 ? (
|
||||
<span className="absolute right-2 top-2 inline-flex h-5 w-5 items-center justify-center rounded-full border border-sky-200/64 bg-sky-200/24 text-[10px] font-semibold text-sky-50">
|
||||
✓
|
||||
</span>
|
||||
) : null}
|
||||
<div className="absolute inset-x-2 bottom-2">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import type { GoalChip } from '@/entities/session';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
@@ -21,6 +22,7 @@ export const SessionGoalField = ({
|
||||
onGoalChange,
|
||||
onGoalChipSelect,
|
||||
}: SessionGoalFieldProps) => {
|
||||
const { sessionGoal } = copy.space;
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,7 +43,7 @@ export const SessionGoalField = ({
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="space-goal-input" className="text-[12px] font-medium text-white/88">
|
||||
이번 25분, 딱 한 가지 <span className="text-sky-100">(필수)</span>
|
||||
{sessionGoal.label} <span className="text-sky-100">{sessionGoal.required}</span>
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -49,10 +51,10 @@ export const SessionGoalField = ({
|
||||
autoFocus={autoFocus}
|
||||
value={goalInput}
|
||||
onChange={(event) => onGoalChange(event.target.value)}
|
||||
placeholder="예: 계약서 1페이지 정리"
|
||||
placeholder={sessionGoal.placeholder}
|
||||
className="h-10 w-full rounded-xl border border-white/14 bg-slate-950/42 px-3 text-sm text-white placeholder:text-white/42 focus:border-sky-200/46 focus:outline-none"
|
||||
/>
|
||||
<p className="text-[11px] text-white/54">크게 말고, 바로 다음 한 조각.</p>
|
||||
<p className="text-[11px] text-white/54">{sessionGoal.hint}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './model/useSoundPlayback';
|
||||
export * from './model/useSoundPresetSelection';
|
||||
export * from './ui/SoundPresetControls';
|
||||
|
||||
323
src/features/sound-preset/model/useSoundPlayback.ts
Normal file
323
src/features/sound-preset/model/useSoundPlayback.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { SoundAssetManifestItem } from '@/entities/media';
|
||||
import { copy } from '@/shared/i18n';
|
||||
|
||||
interface UseSoundPlaybackOptions {
|
||||
selectedPresetId: string;
|
||||
soundAsset?: SoundAssetManifestItem;
|
||||
masterVolume: number;
|
||||
isMuted: boolean;
|
||||
shouldPlay: boolean;
|
||||
}
|
||||
|
||||
const clampVolume = (value: number) => {
|
||||
return Math.min(1, Math.max(0, value / 100));
|
||||
};
|
||||
|
||||
const resolvePlaybackErrorMessage = (error: unknown) => {
|
||||
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||
return copy.soundPlayback.browserDeferred;
|
||||
}
|
||||
|
||||
return copy.soundPlayback.loadFailed;
|
||||
};
|
||||
|
||||
const normalizeMediaUrl = (value: string | null | undefined) => {
|
||||
if (!value || typeof window === 'undefined') {
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value, window.location.href).toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export const useSoundPlayback = ({
|
||||
selectedPresetId,
|
||||
soundAsset,
|
||||
masterVolume,
|
||||
isMuted,
|
||||
shouldPlay,
|
||||
}: UseSoundPlaybackOptions) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const isPlaybackUnlockedRef = useRef(false);
|
||||
const hasUserInteractedRef = useRef(false);
|
||||
const recoveryAttemptedUrlRef = useRef<string | null>(null);
|
||||
const requestedUrlRef = useRef<string | null>(null);
|
||||
const [isReady, setReady] = useState(false);
|
||||
const [isPlaying, setPlaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const activeUrl = useMemo(() => {
|
||||
if (selectedPresetId === 'silent') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return soundAsset?.loopUrl ?? soundAsset?.fallbackLoopUrl ?? null;
|
||||
}, [selectedPresetId, soundAsset?.fallbackLoopUrl, soundAsset?.loopUrl]);
|
||||
|
||||
const normalizedActiveUrl = useMemo(() => {
|
||||
return normalizeMediaUrl(activeUrl);
|
||||
}, [activeUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = new window.Audio();
|
||||
audio.loop = true;
|
||||
audio.preload = 'auto';
|
||||
isPlaybackUnlockedRef.current = false;
|
||||
hasUserInteractedRef.current = false;
|
||||
recoveryAttemptedUrlRef.current = null;
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setReady(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handlePlaying = () => {
|
||||
setPlaying(true);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setPlaying(false);
|
||||
};
|
||||
|
||||
const handleLoadStart = () => {
|
||||
setReady(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
if (!requestedUrlRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReady(false);
|
||||
setPlaying(false);
|
||||
setError(copy.soundPlayback.loadFailed);
|
||||
};
|
||||
|
||||
audio.addEventListener('loadstart', handleLoadStart);
|
||||
audio.addEventListener('canplay', handleCanPlay);
|
||||
audio.addEventListener('playing', handlePlaying);
|
||||
audio.addEventListener('pause', handlePause);
|
||||
audio.addEventListener('error', handleError);
|
||||
audioRef.current = audio;
|
||||
|
||||
return () => {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
audio.removeEventListener('loadstart', handleLoadStart);
|
||||
audio.removeEventListener('canplay', handleCanPlay);
|
||||
audio.removeEventListener('playing', handlePlaying);
|
||||
audio.removeEventListener('pause', handlePause);
|
||||
audio.removeEventListener('error', handleError);
|
||||
audioRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
|
||||
audio.volume = clampVolume(masterVolume);
|
||||
audio.muted = isMuted;
|
||||
}, [isMuted, masterVolume]);
|
||||
|
||||
const unlockPlayback = useCallback(async (requestedUrl?: string | null) => {
|
||||
const audio = audioRef.current;
|
||||
const nextUrl =
|
||||
selectedPresetId === 'silent'
|
||||
? null
|
||||
: requestedUrl ?? activeUrl;
|
||||
const normalizedNextUrl = normalizeMediaUrl(nextUrl);
|
||||
|
||||
if (!audio || !nextUrl || !normalizedNextUrl) {
|
||||
isPlaybackUnlockedRef.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPlaybackUnlockedRef.current) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousMuted = audio.muted;
|
||||
const previousVolume = audio.volume;
|
||||
|
||||
try {
|
||||
if (audio.src !== normalizedNextUrl) {
|
||||
audio.src = normalizedNextUrl;
|
||||
audio.load();
|
||||
}
|
||||
|
||||
audio.muted = true;
|
||||
audio.volume = 0;
|
||||
await audio.play();
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
audio.muted = previousMuted;
|
||||
audio.volume = previousVolume;
|
||||
isPlaybackUnlockedRef.current = true;
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (playbackError) {
|
||||
audio.pause();
|
||||
audio.muted = previousMuted;
|
||||
audio.volume = previousVolume;
|
||||
setPlaying(false);
|
||||
setError(resolvePlaybackErrorMessage(playbackError));
|
||||
return false;
|
||||
}
|
||||
}, [activeUrl, selectedPresetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const markInteraction = () => {
|
||||
hasUserInteractedRef.current = true;
|
||||
};
|
||||
|
||||
window.addEventListener('pointerdown', markInteraction, { passive: true });
|
||||
window.addEventListener('keydown', markInteraction, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', markInteraction);
|
||||
window.removeEventListener('keydown', markInteraction);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeUrl || !normalizedActiveUrl) {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
requestedUrlRef.current = null;
|
||||
recoveryAttemptedUrlRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (audio.src === normalizedActiveUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestedUrlRef.current = normalizedActiveUrl;
|
||||
audio.src = normalizedActiveUrl;
|
||||
audio.load();
|
||||
}, [activeUrl, normalizedActiveUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldPlay || !activeUrl || isPlaybackUnlockedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attemptRecovery = () => {
|
||||
if (recoveryAttemptedUrlRef.current === activeUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
recoveryAttemptedUrlRef.current = activeUrl;
|
||||
void unlockPlayback(activeUrl).then((didUnlock) => {
|
||||
if (!didUnlock && recoveryAttemptedUrlRef.current === activeUrl) {
|
||||
recoveryAttemptedUrlRef.current = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
attemptRecovery();
|
||||
|
||||
if (isPlaybackUnlockedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasUserInteractedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const handleInteraction = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasUserInteractedRef.current = true;
|
||||
attemptRecovery();
|
||||
};
|
||||
|
||||
window.addEventListener('pointerdown', handleInteraction, { passive: true, once: true });
|
||||
window.addEventListener('keydown', handleInteraction, { passive: true, once: true });
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('pointerdown', handleInteraction);
|
||||
window.removeEventListener('keydown', handleInteraction);
|
||||
};
|
||||
}, [activeUrl, shouldPlay, unlockPlayback]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
|
||||
if (!audio) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldPlay || !activeUrl) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlaybackUnlockedRef.current) {
|
||||
audio.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const playAudio = async () => {
|
||||
try {
|
||||
await audio.play();
|
||||
|
||||
if (!cancelled) {
|
||||
setError(null);
|
||||
}
|
||||
} catch (playbackError) {
|
||||
if (!cancelled) {
|
||||
setPlaying(false);
|
||||
setError(resolvePlaybackErrorMessage(playbackError));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void playAudio();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeUrl, shouldPlay]);
|
||||
|
||||
return {
|
||||
activeUrl,
|
||||
isReady,
|
||||
isPlaying,
|
||||
error,
|
||||
unlockPlayback,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { Toggle } from '@/shared/ui';
|
||||
import type { SoundTrackKey } from '../model/useSoundPresetSelection';
|
||||
|
||||
@@ -16,13 +17,7 @@ interface SoundPresetControlsProps {
|
||||
onTrackLevelChange: (track: SoundTrackKey, level: number) => void;
|
||||
}
|
||||
|
||||
const TRACK_LABELS: Record<SoundTrackKey, string> = {
|
||||
white: 'White',
|
||||
rain: 'Rain',
|
||||
cafe: 'Cafe',
|
||||
wave: 'Wave',
|
||||
fan: 'Fan',
|
||||
};
|
||||
const TRACK_LABELS: Record<SoundTrackKey, string> = copy.space.soundPresetControls.trackLabels;
|
||||
|
||||
const clampSliderValue = (value: number) => Math.max(0, Math.min(100, value));
|
||||
|
||||
@@ -39,10 +34,11 @@ export const SoundPresetControls = ({
|
||||
trackLevels,
|
||||
onTrackLevelChange,
|
||||
}: SoundPresetControlsProps) => {
|
||||
const { soundPresetControls } = copy.space;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-[0.11em] text-white/58">Preset</p>
|
||||
<p className="text-xs uppercase tracking-[0.11em] text-white/58">{soundPresetControls.preset}</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SOUND_PRESETS.map((preset) => (
|
||||
<button
|
||||
@@ -66,16 +62,16 @@ export const SoundPresetControls = ({
|
||||
onClick={onToggleMixer}
|
||||
className="inline-flex items-center gap-2 text-xs text-white/70 transition hover:text-white"
|
||||
>
|
||||
<span>{isMixerOpen ? 'Mixer 접기' : 'Mixer 펼치기'}</span>
|
||||
<span>{isMixerOpen ? soundPresetControls.mixerClose : soundPresetControls.mixerOpen}</span>
|
||||
<span className="inline-flex items-center rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-medium text-white/86 ring-1 ring-white/20">
|
||||
더미
|
||||
{soundPresetControls.mock}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isMixerOpen ? (
|
||||
<div className="space-y-3 rounded-xl border border-white/14 bg-white/6 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-white/78">마스터 볼륨</span>
|
||||
<span className="text-xs text-white/78">{soundPresetControls.masterVolume}</span>
|
||||
<span className="text-[11px] text-white/58">{masterVolume}%</span>
|
||||
</div>
|
||||
<input
|
||||
@@ -90,11 +86,11 @@ export const SoundPresetControls = ({
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-white/12 pt-2">
|
||||
<p className="text-xs text-white/78">뮤트</p>
|
||||
<p className="text-xs text-white/78">{soundPresetControls.mute}</p>
|
||||
<Toggle
|
||||
checked={isMuted}
|
||||
onChange={onMuteChange}
|
||||
ariaLabel="마스터 뮤트 토글"
|
||||
ariaLabel={soundPresetControls.muteToggleAriaLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './ui/SpaceSelectCarousel';
|
||||
@@ -1,51 +0,0 @@
|
||||
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
interface SpaceSelectCarouselProps {
|
||||
rooms: RoomTheme[];
|
||||
selectedRoomId: string;
|
||||
onSelect: (roomId: string) => void;
|
||||
}
|
||||
|
||||
export const SpaceSelectCarousel = ({
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
onSelect,
|
||||
}: SpaceSelectCarouselProps) => {
|
||||
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;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={room.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(room.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} 선택`}
|
||||
>
|
||||
<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 ? (
|
||||
<span className="absolute right-2 top-2 inline-flex h-5 w-5 items-center justify-center rounded-full border border-sky-200/64 bg-sky-200/24 text-[10px] font-semibold text-sky-50">
|
||||
✓
|
||||
</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>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
src/features/stats/api/statsApi.ts
Normal file
45
src/features/stats/api/statsApi.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { apiClient } from '@/shared/lib/apiClient';
|
||||
|
||||
export interface FocusTrendPoint {
|
||||
date: string;
|
||||
focusMinutes: number;
|
||||
completedCycles: number;
|
||||
}
|
||||
|
||||
export interface FocusStatsSummary {
|
||||
today: {
|
||||
focusMinutes: number;
|
||||
completedCycles: number;
|
||||
sessionEntries: number;
|
||||
};
|
||||
last7Days: {
|
||||
focusMinutes: number;
|
||||
bestDayLabel: string;
|
||||
bestDayFocusMinutes: number;
|
||||
streakDays: number;
|
||||
startedSessions: number;
|
||||
completedSessions: number;
|
||||
carriedOverCount: number;
|
||||
};
|
||||
recovery: {
|
||||
pausedSessions: number;
|
||||
resumedSessions: number;
|
||||
pauseRecoveryRate: number;
|
||||
awayRecoveryReady: boolean;
|
||||
};
|
||||
trend: FocusTrendPoint[];
|
||||
}
|
||||
|
||||
export const statsApi = {
|
||||
/**
|
||||
* Backend Codex:
|
||||
* - 로그인한 사용자의 집중 통계 요약을 반환한다.
|
||||
* - today, last7Days, trend를 한 번에 내려 프론트가 화면 진입 시 즉시 렌더링할 수 있게 한다.
|
||||
* - 단위는 계산이 쉬운 숫자형(minutes, counts)으로 내려주고, 라벨 포맷팅은 프론트가 맡는다.
|
||||
*/
|
||||
getFocusStatsSummary: async (): Promise<FocusStatsSummary> => {
|
||||
return apiClient<FocusStatsSummary>('api/v1/stats/focus-summary', {
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
};
|
||||
2
src/features/stats/index.ts
Normal file
2
src/features/stats/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './api/statsApi';
|
||||
export * from './model/useFocusStats';
|
||||
431
src/features/stats/model/useFocusStats.ts
Normal file
431
src/features/stats/model/useFocusStats.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { statsApi, type FocusStatsSummary } from '../api/statsApi';
|
||||
|
||||
type StatsSource = 'api' | 'mock';
|
||||
|
||||
const parseDurationLabelToMinutes = (label: string) => {
|
||||
const hourMatch = label.match(/(\d+)h/);
|
||||
const minuteMatch = label.match(/(\d+)m/);
|
||||
const hours = hourMatch ? Number(hourMatch[1]) : 0;
|
||||
const minutes = minuteMatch ? Number(minuteMatch[1]) : 0;
|
||||
return hours * 60 + minutes;
|
||||
};
|
||||
|
||||
const formatMinutesLabel = (minutes: number) => {
|
||||
const safeMinutes = Math.max(0, minutes);
|
||||
const hourPart = Math.floor(safeMinutes / 60);
|
||||
const minutePart = safeMinutes % 60;
|
||||
|
||||
if (hourPart === 0) {
|
||||
return `${minutePart}분`;
|
||||
}
|
||||
|
||||
if (minutePart === 0) {
|
||||
return `${hourPart}시간`;
|
||||
}
|
||||
|
||||
return `${hourPart}시간 ${minutePart}분`;
|
||||
};
|
||||
|
||||
const formatPercent = (value: number) => `${Math.round(value * 100)}%`;
|
||||
|
||||
const buildMockSummary = (): FocusStatsSummary => {
|
||||
return {
|
||||
today: {
|
||||
focusMinutes: parseDurationLabelToMinutes(TODAY_STATS[0]?.value ?? '0m'),
|
||||
completedCycles: Number.parseInt(TODAY_STATS[1]?.value ?? '0', 10) || 0,
|
||||
sessionEntries: Number.parseInt(TODAY_STATS[2]?.value ?? '0', 10) || 0,
|
||||
},
|
||||
last7Days: {
|
||||
focusMinutes: parseDurationLabelToMinutes(WEEKLY_STATS[0]?.value ?? '0m'),
|
||||
bestDayLabel: WEEKLY_STATS[1]?.value ?? '수요일',
|
||||
bestDayFocusMinutes: 210,
|
||||
streakDays: 4,
|
||||
startedSessions: 6,
|
||||
completedSessions: 4,
|
||||
carriedOverCount: 1,
|
||||
},
|
||||
recovery: {
|
||||
pausedSessions: 3,
|
||||
resumedSessions: 2,
|
||||
pauseRecoveryRate: 2 / 3,
|
||||
awayRecoveryReady: true,
|
||||
},
|
||||
trend: [],
|
||||
};
|
||||
};
|
||||
|
||||
export interface WeeklyReviewMetric {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface WeeklyReviewSection {
|
||||
title: string;
|
||||
summary: string;
|
||||
metrics: WeeklyReviewMetric[];
|
||||
availability: 'ready' | 'limited';
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export type ReviewCarryHint = 'smaller' | 'closure' | 'start' | 'steady';
|
||||
|
||||
export interface WeeklyReviewViewModel {
|
||||
periodLabel: string;
|
||||
snapshotTitle: string;
|
||||
snapshotSummary: string;
|
||||
snapshotMetrics: WeeklyReviewMetric[];
|
||||
startQuality: WeeklyReviewSection;
|
||||
recoveryQuality: WeeklyReviewSection;
|
||||
completionQuality: WeeklyReviewSection;
|
||||
carryForward: {
|
||||
hintKey: ReviewCarryHint;
|
||||
atmosphereId: string;
|
||||
atmosphereLabel: string;
|
||||
sceneId: string;
|
||||
soundPresetId: string | null;
|
||||
durationMinutes: number;
|
||||
keepDoing: string;
|
||||
tryNext: string;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
};
|
||||
}
|
||||
|
||||
const buildSnapshotSummary = (summary: FocusStatsSummary) => {
|
||||
const { startedSessions, completedSessions, carriedOverCount } = summary.last7Days;
|
||||
const completionRate = startedSessions > 0 ? completedSessions / startedSessions : 0;
|
||||
|
||||
if (startedSessions === 0) {
|
||||
return copy.stats.reviewSnapshotEmpty;
|
||||
}
|
||||
|
||||
if (completionRate >= 0.6 && carriedOverCount === 0) {
|
||||
return copy.stats.reviewSnapshotStrong;
|
||||
}
|
||||
|
||||
if (completionRate >= 0.4) {
|
||||
return copy.stats.reviewSnapshotSteady;
|
||||
}
|
||||
|
||||
return copy.stats.reviewSnapshotRecoveryNeeded;
|
||||
};
|
||||
|
||||
const buildStartSummary = (summary: FocusStatsSummary) => {
|
||||
const averageDepthMinutes =
|
||||
summary.last7Days.startedSessions > 0
|
||||
? Math.round(summary.last7Days.focusMinutes / summary.last7Days.startedSessions)
|
||||
: 0;
|
||||
|
||||
if (summary.last7Days.startedSessions === 0) {
|
||||
return copy.stats.reviewStartEmpty;
|
||||
}
|
||||
|
||||
return copy.stats.reviewStartSummary(
|
||||
summary.last7Days.startedSessions,
|
||||
summary.last7Days.bestDayLabel,
|
||||
formatMinutesLabel(averageDepthMinutes),
|
||||
);
|
||||
};
|
||||
|
||||
const buildCompletionSummary = (summary: FocusStatsSummary) => {
|
||||
const completionRate =
|
||||
summary.last7Days.startedSessions > 0
|
||||
? summary.last7Days.completedSessions / summary.last7Days.startedSessions
|
||||
: 0;
|
||||
|
||||
if (summary.last7Days.startedSessions === 0) {
|
||||
return copy.stats.reviewCompletionEmpty;
|
||||
}
|
||||
|
||||
if (summary.last7Days.carriedOverCount === 0 && completionRate >= 0.6) {
|
||||
return copy.stats.reviewCompletionStrong;
|
||||
}
|
||||
|
||||
return copy.stats.reviewCompletionSummary(
|
||||
formatPercent(completionRate),
|
||||
summary.last7Days.carriedOverCount,
|
||||
);
|
||||
};
|
||||
|
||||
const buildRecoverySummary = (summary: FocusStatsSummary, source: StatsSource) => {
|
||||
if (source === 'mock') {
|
||||
return copy.stats.reviewRecoveryMockSummary;
|
||||
}
|
||||
|
||||
if (summary.recovery.pausedSessions === 0) {
|
||||
return copy.stats.reviewRecoveryNoPauseSummary;
|
||||
}
|
||||
|
||||
return copy.stats.reviewRecoveryApiSummary(
|
||||
formatPercent(summary.recovery.pauseRecoveryRate),
|
||||
summary.recovery.resumedSessions,
|
||||
summary.recovery.pausedSessions,
|
||||
);
|
||||
};
|
||||
|
||||
const buildRecoverySection = (
|
||||
summary: FocusStatsSummary,
|
||||
source: StatsSource,
|
||||
): WeeklyReviewSection => {
|
||||
if (source === 'mock') {
|
||||
return {
|
||||
title: copy.stats.reviewRecoveryTitle,
|
||||
summary: copy.stats.reviewRecoveryMockSummary,
|
||||
availability: 'ready',
|
||||
metrics: [
|
||||
{
|
||||
id: 'recovery-pause',
|
||||
label: copy.stats.reviewPauseRecovery,
|
||||
value: '67%',
|
||||
hint: copy.stats.reviewPauseRecoveryHint,
|
||||
},
|
||||
{
|
||||
id: 'recovery-away',
|
||||
label: copy.stats.reviewAwayRecovery,
|
||||
value: '50%',
|
||||
hint: copy.stats.reviewAwayRecoveryHint,
|
||||
},
|
||||
],
|
||||
note: copy.stats.reviewRecoveryMockNote,
|
||||
};
|
||||
}
|
||||
|
||||
const availability = summary.recovery.awayRecoveryReady ? 'ready' : 'limited';
|
||||
const metrics =
|
||||
summary.recovery.pausedSessions > 0
|
||||
? [
|
||||
{
|
||||
id: 'recovery-pause-rate',
|
||||
label: copy.stats.reviewPauseRecovery,
|
||||
value: formatPercent(summary.recovery.pauseRecoveryRate),
|
||||
hint: copy.stats.reviewPauseRecoveryHint,
|
||||
},
|
||||
{
|
||||
id: 'recovery-resumed-sessions',
|
||||
label: copy.stats.reviewResumedSessions,
|
||||
value: `${summary.recovery.resumedSessions}${copy.stats.countUnit}`,
|
||||
hint: copy.stats.reviewResumedSessionsHint,
|
||||
},
|
||||
{
|
||||
id: 'recovery-paused-sessions',
|
||||
label: copy.stats.reviewPausedSessions,
|
||||
value: `${summary.recovery.pausedSessions}${copy.stats.countUnit}`,
|
||||
hint: copy.stats.reviewPausedSessionsHint,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
title: copy.stats.reviewRecoveryTitle,
|
||||
summary: buildRecoverySummary(summary, source),
|
||||
availability,
|
||||
metrics,
|
||||
note: availability === 'limited' ? copy.stats.reviewRecoveryPartialNote : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const buildCarryForward = (summary: FocusStatsSummary): WeeklyReviewViewModel['carryForward'] => {
|
||||
const completionRate =
|
||||
summary.last7Days.startedSessions > 0
|
||||
? summary.last7Days.completedSessions / summary.last7Days.startedSessions
|
||||
: 0;
|
||||
|
||||
const keepDoing =
|
||||
summary.last7Days.bestDayFocusMinutes > 0
|
||||
? copy.stats.reviewCarryKeep(summary.last7Days.bestDayLabel)
|
||||
: copy.stats.reviewCarryKeepGeneric;
|
||||
|
||||
let hintKey: ReviewCarryHint = 'steady';
|
||||
let tryNext: string = copy.stats.reviewCarryTryDefault;
|
||||
|
||||
if (summary.last7Days.carriedOverCount >= 2) {
|
||||
hintKey = 'smaller';
|
||||
tryNext = copy.stats.reviewCarryTrySmaller;
|
||||
} else if (completionRate < 0.45) {
|
||||
hintKey = 'closure';
|
||||
tryNext = copy.stats.reviewCarryTryClosure;
|
||||
} else if (summary.last7Days.startedSessions <= 3) {
|
||||
hintKey = 'start';
|
||||
tryNext = copy.stats.reviewCarryTryStart;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
review: 'weekly',
|
||||
carryHint: hintKey,
|
||||
entryAtmosphereId: 'forest-draft',
|
||||
entryDurationMinutes: '50',
|
||||
});
|
||||
|
||||
return {
|
||||
hintKey,
|
||||
atmosphereId: 'forest-draft',
|
||||
atmosphereLabel: 'Forest Draft',
|
||||
sceneId: 'forest',
|
||||
soundPresetId: 'forest-birds',
|
||||
durationMinutes: 50,
|
||||
keepDoing,
|
||||
tryNext,
|
||||
ctaLabel: copy.stats.reviewCarryCta,
|
||||
ctaHref: `/app?${params.toString()}`,
|
||||
};
|
||||
};
|
||||
|
||||
const buildReviewFromSummary = (
|
||||
summary: FocusStatsSummary,
|
||||
source: StatsSource,
|
||||
): WeeklyReviewViewModel => {
|
||||
const completionRate =
|
||||
summary.last7Days.startedSessions > 0
|
||||
? summary.last7Days.completedSessions / summary.last7Days.startedSessions
|
||||
: 0;
|
||||
const averageDepthMinutes =
|
||||
summary.last7Days.startedSessions > 0
|
||||
? Math.round(summary.last7Days.focusMinutes / summary.last7Days.startedSessions)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
periodLabel: copy.stats.reviewPeriodLabel,
|
||||
snapshotTitle: copy.stats.reviewTitle,
|
||||
snapshotSummary: buildSnapshotSummary(summary),
|
||||
snapshotMetrics: [
|
||||
{
|
||||
id: 'snapshot-started',
|
||||
label: copy.stats.reviewStarted,
|
||||
value: `${summary.last7Days.startedSessions}${copy.stats.countUnit}`,
|
||||
hint: copy.stats.reviewStartedHint,
|
||||
},
|
||||
{
|
||||
id: 'snapshot-completed',
|
||||
label: copy.stats.reviewCompleted,
|
||||
value: `${summary.last7Days.completedSessions}${copy.stats.countUnit}`,
|
||||
hint: copy.stats.reviewCompletedHint,
|
||||
},
|
||||
{
|
||||
id: 'snapshot-focus',
|
||||
label: copy.stats.reviewFocusMinutes,
|
||||
value: formatMinutesLabel(summary.last7Days.focusMinutes),
|
||||
hint: copy.stats.reviewFocusMinutesHint,
|
||||
},
|
||||
{
|
||||
id: 'snapshot-carried',
|
||||
label: copy.stats.reviewCarriedOver,
|
||||
value: `${summary.last7Days.carriedOverCount}${copy.stats.countUnit}`,
|
||||
hint: copy.stats.reviewCarriedOverHint,
|
||||
},
|
||||
],
|
||||
startQuality: {
|
||||
title: copy.stats.reviewStartTitle,
|
||||
summary: buildStartSummary(summary),
|
||||
availability: 'ready',
|
||||
metrics: [
|
||||
{
|
||||
id: 'start-sessions',
|
||||
label: copy.stats.reviewStarted,
|
||||
value: `${summary.last7Days.startedSessions}${copy.stats.countUnit}`,
|
||||
hint: copy.stats.reviewStartedHint,
|
||||
},
|
||||
{
|
||||
id: 'start-depth',
|
||||
label: copy.stats.reviewAverageDepth,
|
||||
value: formatMinutesLabel(averageDepthMinutes),
|
||||
hint: copy.stats.reviewAverageDepthHint,
|
||||
},
|
||||
{
|
||||
id: 'start-best-day',
|
||||
label: copy.stats.reviewBestDay,
|
||||
value: summary.last7Days.bestDayLabel,
|
||||
hint: copy.stats.reviewBestDayHint(summary.last7Days.bestDayFocusMinutes),
|
||||
},
|
||||
],
|
||||
},
|
||||
recoveryQuality: buildRecoverySection(summary, source),
|
||||
completionQuality: {
|
||||
title: copy.stats.reviewCompletionTitle,
|
||||
summary: buildCompletionSummary(summary),
|
||||
availability: 'ready',
|
||||
metrics: [
|
||||
{
|
||||
id: 'completion-rate',
|
||||
label: copy.stats.reviewCompletionRate,
|
||||
value: formatPercent(completionRate),
|
||||
hint: copy.stats.reviewCompletionRateHint,
|
||||
},
|
||||
{
|
||||
id: 'completion-goals',
|
||||
label: copy.stats.reviewCompleted,
|
||||
value: `${summary.last7Days.completedSessions}${copy.stats.countUnit}`,
|
||||
hint: copy.stats.reviewCompletedHint,
|
||||
},
|
||||
{
|
||||
id: 'completion-carry',
|
||||
label: copy.stats.reviewCarriedOver,
|
||||
value: `${summary.last7Days.carriedOverCount}${copy.stats.countUnit}`,
|
||||
hint: copy.stats.reviewCarriedOverHint,
|
||||
},
|
||||
],
|
||||
},
|
||||
carryForward: buildCarryForward(summary),
|
||||
};
|
||||
};
|
||||
|
||||
interface UseFocusStatsResult {
|
||||
summary: FocusStatsSummary;
|
||||
review: WeeklyReviewViewModel;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
source: StatsSource;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useFocusStats = (): UseFocusStatsResult => {
|
||||
const initialSummary = buildMockSummary();
|
||||
const [summary, setSummary] = useState<FocusStatsSummary>(initialSummary);
|
||||
const [review, setReview] = useState<WeeklyReviewViewModel>(
|
||||
buildReviewFromSummary(initialSummary, 'mock'),
|
||||
);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [source, setSource] = useState<StatsSource>('mock');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const nextSummary = await statsApi.getFocusStatsSummary();
|
||||
setSummary(nextSummary);
|
||||
setReview(buildReviewFromSummary(nextSummary, 'api'));
|
||||
setSource('api');
|
||||
setError(null);
|
||||
} catch (nextError) {
|
||||
const message =
|
||||
nextError instanceof Error ? nextError.message : copy.stats.loadFailed;
|
||||
const nextSummary = buildMockSummary();
|
||||
setSummary(nextSummary);
|
||||
setReview(buildReviewFromSummary(nextSummary, 'mock'));
|
||||
setSource('mock');
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
summary,
|
||||
review,
|
||||
isLoading,
|
||||
error,
|
||||
source,
|
||||
refetch: load,
|
||||
};
|
||||
};
|
||||
2
src/shared/config/authTokens.ts
Normal file
2
src/shared/config/authTokens.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const TOKEN_COOKIE_KEY = 'vr_access_token';
|
||||
export const REFRESH_TOKEN_COOKIE_KEY = 'vr_refresh_token';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user