Compare commits

..

91 Commits

Author SHA1 Message Date
6df34a0eb7 fix(space): 미디어 재진입 복원 안정화 2026-03-18 20:19:38 +09:00
9b013f1843 refactor(space): 하단 다이내믹 독 형태의 잡념 수집기 UIUX 재설계 및 오류 수정
- 중앙 모놀리스 영역에서 벗어나 화면 최하단의 플로팅 바 형식으로 UI 전면 개편

- 마우스 호버 시 알약(Pill) 형태로 변하고, 활성화 시 거대한 입력창으로 몰핑되는 부드러운 트랜지션 적용

- backdrop-filter 팝인 버그 수정을 위한 transition 개별 요소 적용

- Cmd+K (Mac) 및 Ctrl+K (Windows) 단축키 인식 오류 수정 및 전역 이벤트 리스너 리팩토링

- 마우스 호버 시 뷰가 부르르 떨리는 현상(Jittering)을 가상 요소(before) 히트박스로 원천 차단
2026-03-18 14:38:21 +09:00
14d7165ffe style(space): deepen focus hud stage contrast 2026-03-18 13:31:40 +09:00
d193973eb5 style(space): align goal complete sheet with end session tone 2026-03-18 00:11:15 +09:00
f580ab8a72 fix(space): silence microstep status toast 2026-03-17 21:14:32 +09:00
167e64534f refactor(space): refine thought orb interaction 2026-03-17 21:11:28 +09:00
3a06881634 docs(ui): codify view reuse rule 2026-03-17 20:52:27 +09:00
bdbcf3c3f1 refactor(space): refine end session modal motion 2026-03-17 20:50:28 +09:00
99146fb08b fix(space): restore end session modal styling 2026-03-17 19:29:42 +09:00
3204609f3d feat(space): unify session closure result variants 2026-03-17 19:23:06 +09:00
fd1b7123e2 fix(space): stabilize unfinished end session close 2026-03-17 18:38:32 +09:00
cd91ff9ae5 refactor(space): simplify end session completion flow 2026-03-17 18:13:28 +09:00
aff1a007b2 refactor(space): polish focus hud ui 2026-03-17 15:06:45 +09:00
f21129fc5d fix(space): remove stale setup drawer flow 2026-03-17 14:42:56 +09:00
13a94ef42d feat(space): thought orb capture ui 추가 2026-03-17 14:11:11 +09:00
2afbe3ce7a feat(space): unify end session flow and en-first copy 2026-03-17 14:04:13 +09:00
5026138ad9 fix(space): 종료와 목표 완료 흐름 재정렬 2026-03-17 12:57:59 +09:00
4bbee36e1e feat(space): explicit end session close flow 적용 2026-03-17 12:45:38 +09:00
1d2ce85cfd feat(space): 종료 결과 모달과 current session thought 복원 추가 2026-03-16 20:08:50 +09:00
6194c19f3b fix(space): hidden goal complete modal click 차단 해제 2026-03-16 18:51:13 +09:00
abc1525fe2 feat(space): goal complete를 중앙 모달로 재구성 2026-03-16 18:44:59 +09:00
38abc1e0c7 feat(flow): focus session api v2 웹 계약 전환 2026-03-16 17:30:52 +09:00
f4910238a0 chore(web): 사용하지 않는 legacy 위젯 정리 2026-03-16 16:21:12 +09:00
ec941f3cde feat(space): timer 종료 모달과 10분 연장 추가 2026-03-16 16:17:41 +09:00
627bd82706 fix(space): stale hud 참조 정리 2026-03-16 15:37:58 +09:00
b91fdbcb67 refactor(space): focus hud를 inline 구조로 단순화 2026-03-16 15:17:01 +09:00
fb2729193f fix(app): entry stage를 위로 재배치 2026-03-16 14:47:54 +09:00
c63ddc4e98 fix(app): premium entry 조정과 duration 입력 버그 수정 2026-03-16 14:35:26 +09:00
6b25a18d5a feat(stats): observatory tone으로 review 재구성 2026-03-16 13:49:01 +09:00
e16a182499 feat(stats): immersive weekly review stage 적용 2026-03-16 13:41:58 +09:00
8f4a69fc77 feat(app): premium immersive entry ui 적용 2026-03-16 13:26:15 +09:00
81e969c116 feat(app): atmosphere entry shell 고급화 2026-03-16 12:37:36 +09:00
16d620ee4a fix(flow): app entry를 no-session 전용으로 단순화 2026-03-16 12:28:28 +09:00
721212ec1f feat(app): atmosphere entry shell 1차 구현 2026-03-16 12:12:03 +09:00
c6e342e93d docs(docs): 화면별 current와 archive 구조로 분리 2026-03-16 11:46:13 +09:00
38a9d1e762 docs(docs): 문서를 화면과 용도별 폴더로 재구성 2026-03-16 11:31:03 +09:00
acfa8f4f48 docs(docs): 문서 인덱스와 화면별 가이드 재정리 2026-03-16 11:24:14 +09:00
3471c96972 docs(product): stale app flow 문서 정리 2026-03-16 11:18:39 +09:00
56385ec2eb docs(product): app atmosphere entry spec 추가 2026-03-16 11:04:02 +09:00
3c5154178d fix(space): break와 recovery 상태의 완료 경로 복구 2026-03-15 23:10:29 +09:00
728330bf74 feat(app): paused session takeover flow 추가 2026-03-15 19:57:18 +09:00
3aba789c97 feat(stats): recovery 통계를 서버 계약으로 연결 2026-03-15 19:18:05 +09:00
1b01ceaa8b feat(flow): paused resume gate와 auto-resume 연결 2026-03-15 18:52:19 +09:00
6b70d07e3c fix(space): pause 중 app redirect 방지 2026-03-15 18:47:13 +09:00
6a0710d023 feat(flow): session routing contract 정리 2026-03-15 18:40:00 +09:00
cbeeb38413 docs(flow): paused session re-entry spec 추가 2026-03-15 18:30:22 +09:00
0f01ecd8a1 docs(product): alignment findings ledger 시작 2026-03-15 13:31:46 +09:00
b3853c98d2 docs(product): alignment audit plan 추가 2026-03-15 11:51:53 +09:00
6bf3336aec fix(flow): 기획-구현 불일치 정렬 2026-03-15 11:46:21 +09:00
de95505d2f feat(space): secondary weekly review teaser 추가 2026-03-14 20:00:38 +09:00
5d3a5ac8ac feat(stats): pro personalized handoff 추가 2026-03-14 19:45:55 +09:00
c8b00905cd feat(app): weekly review return handoff 연결 2026-03-14 19:39:41 +09:00
fe908ec415 feat(app): weekly review teaser 진입 추가 2026-03-14 19:35:01 +09:00
445ef54528 docs(product): weekly review entry flow spec 추가 2026-03-14 19:27:46 +09:00
dc97a78fdd feat(stats): weekly review snapshot 1차 구현 2026-03-14 19:22:58 +09:00
679601d201 docs(product): weekly review reframe spec 추가 2026-03-14 19:13:35 +09:00
74e44fff69 fix(space): rail과 수정 액션 역할 분리 2026-03-14 19:04:00 +09:00
0b8c207fe2 fix(space): intent 카드 dismissal 규칙 정리 2026-03-14 18:56:27 +09:00
729afe0cbf fix(space): 목표 수정 affordance를 명시화 2026-03-14 18:51:43 +09:00
278fc11135 feat(space): 목표 카드를 collapsed rail로 재설계 2026-03-14 18:46:27 +09:00
b0fe2887c6 fix(space): recovery 트레이 공통 레이아웃 정리 2026-03-14 18:30:23 +09:00
425943cf89 fix(space): next beat 문구를 초심자 기준으로 정리 2026-03-14 18:27:30 +09:00
9abe868db6 fix(space): next beat 트레이 레이아웃 보정 2026-03-14 18:24:32 +09:00
caf53f0b68 fix(space): recovery 트레이 모션 polish 2026-03-14 18:22:52 +09:00
cc3eafb2fa feat(space): recovery 카피와 CTA 위계 분리 2026-03-14 18:16:03 +09:00
4421e776b2 docs(roadmap): 코어 루프 진행 상태 반영 2026-03-14 18:07:23 +09:00
fe67597320 feat(space): break와 return 톤 분리 2026-03-14 18:05:59 +09:00
a27cce9a67 fix(space): pause refocus 트레이 가독성 정리 2026-03-14 18:03:32 +09:00
b4ed94cf1b feat(core-loop): /app 진입과 /space 복구 흐름 구현 2026-03-14 18:02:50 +09:00
bc08a049b6 fix(space): 정리된 intent hud와 리뷰 반영 2026-03-14 16:28:26 +09:00
6154bd54a8 fix(landing): 인증 상태에 따라 CTA 경로 분기 2026-03-14 00:48:24 +09:00
a1424a4794 fix(app): 배경 공간 드래그 스크롤 클릭 충돌 수정 2026-03-13 16:22:11 +09:00
88bb4f40b8 feat(space/hud): Exit 버튼 좌측 하단 Invisible Door UI로 재배치
맥락:
- 기존의 우측 상단 Exit(나가기) 버튼이 너무 동떨어져 있었음.
- 목표(Goal) 패널 하단에 Exit 버튼을 두려는 시도가 있었으나, '목표 유지'와 '목표 포기(Exit)'라는 상반된 의미가 한 공간에 묶여 인지적 충돌을 발생시킴.
- 몰입을 방해하지 않는 투명함(Invisible UI)과 본능적인 이탈 경로가 필요함.

변경사항:
- SpaceToolsDockWidget에 새로운 좌측 하단(Bottom-Left) 모서리 Exit 버튼 렌더링 영역 추가.
- 평소에는 투명한 Escape(⎋) 아이콘만 노출하여 배경 공간의 방해 최소화.
- 사용자가 마우스를 Hover할 때만 알약(Pill) 형태로 부드럽게 확장(Expansion)되며 ExitHoldButton(Bar)이 나타나는 고급 인터랙션 구현.
- FloatingGoalWidget에 테스트로 추가했던 Exit 버튼 코드 원복(제거) 및 SpaceFocusHudWidget, SpaceWorkspaceWidget의 불필요한 prop 전달 정리.

검증:
- npm run build 정상 통과.

세션-상태: 몰입 공간(/space)의 하이엔드 UI 레이아웃 재배치 및 디자인 고도화 완료.
세션-다음: 향후 필요 시 통계(Analytics) 또는 결제(Paywall) 세부 기능 구현.
세션-리스크: 없음.
2026-03-13 15:26:53 +09:00
abdde2a8ae feat(space/app): app 진입부 및 space 몰입 환경(HUD/Tools) 프리미엄 UI 리팩토링
맥락:
- 기존 app 대시보드와 space 화면의 UI가 SaaS 툴처럼 딱딱하고 투박하여, 유저가 기꺼이 지갑을 열 만한 몰입감과 고급스러움(Premium feel)이 부족함.
- 인지적 과부하를 줄이기 위해 제안된 '첫 5분 행동(Micro-step)'이 타이머 영역에 묻혀 있어 행동 유발 효과가 미미함.

변경사항:
- app: 컨테이너 박스를 제거하고 전체 배경 화면(Immersive Background)과 Glassmorphism을 활용한 1.5 Step 진입 플로우로 전면 개편.
- space/hud: 하단의 두꺼운 타이머 패널을 초박형(Slim) 글라스 알약 형태로 축소하여 배경 씬의 개방감 확보.
- space/hud: 목표(Goal)와 첫 단계(Micro-step)를 분리하여 좌측 상단의 우아한 Floating UI로 재배치하고, 체크 완료 시 사라지는 도파민 인터랙션 추가.
- space/tools: 흩어져 있던 노트, 사운드, 설정 도구들을 우측 레일(Right-Rail)로 통합하고 팝오버 디자인을 고급화함.
- ui/contrast: 밝은 배경에서도 텍스트가 잘 보이도록 좌측 상단 비네팅(Vignette) 및 다중 텍스트 그림자(Multi-layered Shadow) 효과 적용.

검증:
- npm run build 정상 통과 확인.
- 브라우저 상에서 micro-step 완료 애니메이션 및 도구막대 팝오버 슬라이드 동작 확인.

세션-상태: app 진입부터 space 몰입까지의 코어 UX/UI 하이엔드 개편 완료.
세션-다음: 프로 요금제(PRO) 전환 유도(Paywall) 흐름 및 상세 분석 리포트(Analytics) 뷰 구현.
세션-리스크: 없음.
2026-03-13 14:57:35 +09:00
2506dd53a7 feat(app): focus entry surface로 진입 화면 재구성 2026-03-13 09:54:33 +09:00
698c124ade fix: 오디오가 재생되지 않는 문제 수정
- SpaceWorkspaceWidget 내 순환 참조를 피하기 위해 하드코딩되었던 `shouldPlay: false` 문제 해결
- 핵심 UI 상태(workspaceMode, previewPlaybackState 등)를 SpaceWorkspaceWidget 최상단으로 끌어올림(Lifting State Up)
- useHudStatusLine의 중복 호출 제거
2026-03-11 16:21:44 +09:00
972be117cb fix: 매니페스트 로드 실패 시 로컬 자산으로 안전하게 대체되지 않는 버그 수정
- normalizeMediaManifest에서 빈 데이터 수신 시 DEFAULT_MEDIA_MANIFEST를 활용하도록 수정
- 매니페스트 로드 실패 시 상세 정보(URL, 상태 코드)를 에러 메시지에 포함
- useMediaCatalog에서 fetch 에러 발생 시 명시적으로 로컬 기반 매니페스트를 적용하도록 보강
2026-03-11 15:16:00 +09:00
35f1dfb92d refactor: FSD 구조 강화 및 파일 500줄 제한에 따른 대규모 리팩토링
- SpaceWorkspaceWidget 로직을 전용 훅 및 유틸리티로 분리 (900줄 -> 300줄)
- useSpaceWorkspaceSelection 훅을 기능별(영속성, 진단 등) 소형 훅으로 분리
- SpaceToolsDockWidget의 상태 및 핸들러 로직 추출
- 거대 i18n 번역 파일(ko.ts)을 도메인별 메시지 파일로 구조화
- AdminConsoleWidget 누락분 추가 및 미디어 엔티티 타입 오류 수정
2026-03-11 15:08:36 +09:00
7867bd39ca style(space): stage 배경 overscan과 문서 상태 갱신
맥락:
- space stage 배경 pan 애니메이션 중 가장자리 빈틈이 보일 수 있었다.
- 관련 코드와 세션 문서 상태를 함께 맞춰둘 필요가 있었다.

변경사항:
- SpaceWorkspaceWidget 의 stage background layer 를 로 확장했다.
- 90_current_state, session_brief 에 overscan 보정과 관련 리스크를 반영했다.
- work.md 를 다음 브라우저 QA 작업 기준으로 갱신했다.

검증:
- npx tsc --noEmit

세션-상태: stage 배경 overscan 보정과 문서 정리를 마쳤다.
세션-다음: forest/green-forest manifest 변형을 실제 브라우저에서 QA 한다.
세션-리스크: overscan 으로 좁은 화면에서 배경 crop 체감이 조금 더 커질 수 있다.
2026-03-11 13:46:59 +09:00
4717bb3a1a fix(space): 배경 asset fallback 경로와 scene alias 해석 보강
맥락:
- /space 에서 forest 배경이 remote manifest asset 대신 기본 이미지로 조용히 fallback 될 수 있었다.
- scene key alias 와 manifest 실패 상태가 코드상 드러나지 않아 원인 추적이 어려웠다.

변경사항:
- media scene asset key 를 alias-aware 하게 정규화하고 asset source(fallback|remote) 메타를 추가했다.
- useMediaCatalog 가 remote manifest 실패와 fallback 사용 여부를 노출하도록 보강했다.
- SpaceWorkspaceWidget 에서 manifest 실패와 scene fallback 사용을 진단 로그/상태 메시지로 남기도록 정리했다.
- docs/work.md, docs/90_current_state.md, docs/session_brief.md 를 이번 작업 기준으로 갱신했다.

검증:
- npx tsc --noEmit

세션-상태: /space 배경 asset lookup 과 manifest fallback 진단을 보강했다.
세션-다음: forest/green-forest manifest 변형을 실제 브라우저에서 QA 한다.
세션-리스크: alias 목록 밖의 legacy scene id 는 추가 정규화가 필요할 수 있다.
2026-03-11 13:35:44 +09:00
9811134d8a feat(space): persist media selection and stabilize sound playback 2026-03-10 17:36:10 +09:00
c47f60163d refactor(admin): scene 업로드 화면을 단순화 2026-03-10 14:30:25 +09:00
1717f335f0 refactor(i18n): 사용자 문구 참조를 중앙화 2026-03-10 13:32:37 +09:00
92a509ebb6 feat(auth): 랜딩 시작 분기와 앱 라우트 가드를 추가 2026-03-10 13:30:09 +09:00
1c55f74132 feat(inbox): 백엔드 API 연동하여 생각(메모) 영속화 적용 2026-03-10 12:53:49 +09:00
986b9ba94b feat(admin): 관리자 대시보드와 미디어 자산 UI를 추가 2026-03-09 20:09:10 +09:00
cceaa6bd82 fix(space): HUD 시작 흐름과 컨트롤 상태를 정리 2026-03-09 13:13:53 +09:00
675014166a refactor(space): scene 도메인과 current session 계약을 정리 2026-03-09 13:05:44 +09:00
8184915cb1 fix(space-focus): 목표 안내를 상단 토스트로 통합
맥락:
- space 진입 직후 목표를 확정하면 하단 HUD 위에 GoalFlashOverlay가 노출되어 상단 토스트 흐름과 UI 기준점이 분리되어 있었다.

변경사항:
- GoalFlashOverlay 컴포넌트를 제거했다.
- SpaceFocusHudWidget에서 집중 진입 시점과 paused -> running 복귀 시점의 목표 안내를 onStatusMessage로 올리도록 변경했다.
- 목표 안내가 기존 FocusTopToast를 통해 상단에서 일관되게 보이도록 정리했다.

검증:
- npm run lint

세션-상태: 목표 안내가 하단 오버레이 없이 상단 토스트만 사용하는 상태
세션-다음: 실제 사운드/배경 적용 시 상단 상태 메시지 우선순위를 함께 점검
세션-리스크: 연속 상태 메시지가 짧은 간격으로 발생하면 토스트 큐 길이에 따라 일부 메시지가 뒤로 밀릴 수 있음
2026-03-07 18:37:05 +09:00
d18d9b2bb9 feat(api): 세션·통계·설정 API 연동 기반을 추가
맥락:
- 실제 세션 엔진과 통계·설정 저장을 백엔드와 연결할 프론트 API 경계를 먼저 정리할 필요가 있었다.

변경사항:
- focus session, stats, preferences API 계층과 타입을 추가하고 메서드 주석에 Backend Codex 지시 사항을 작성했다.
- /space를 현재 세션 조회, 시작, 일시정지, 재개, 다시 시작, 완료, 종료 API 흐름에 연결하고 API 실패 시 로컬 미리보기 fallback을 유지했다.
- /stats와 /settings를 API 기반 fetch/save 구조로 전환하고 auth/apiClient를 보강했다.
- React 19 규칙에 맞게 관련 훅과 HUD/시트 구현을 정리해 lint/build가 통과하도록 보정했다.

검증:
- npm run lint
- npm run build

세션-상태: 프론트에서 세션·통계·설정 API를 호출할 준비가 된 상태
세션-다음: 백엔드가 주석에 맞춘 엔드포인트와 응답 스키마를 구현하도록 협업
세션-리스크: 실제 서버 응답 필드명이 현재 타입과 다르면 프론트 매핑 조정이 추가로 필요
2026-03-07 17:54:15 +09:00
09b02f4168 refactor(space): 목표 변경 토스트 위치 변경 2026-03-07 02:41:25 +09:00
165 changed files with 15290 additions and 3651 deletions

View File

@@ -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`

View File

@@ -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)
- [work.md](./work.md)
- [workFlow.md](./workFlow.md)
- [session_brief.md](./session_brief.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. 이 문서에서 지금 다루는 화면 섹션 확인
- 구현 범위는 항상 UI 목업 + 더미 데이터 + 토스트 수준으로 유지한다.
- `page.tsx`는 조합만 담당하고 비즈니스 로직은 `features/widgets/entities`로 이동한다.
- 작업 종료 시 `90_current_state.md`를 반드시 업데이트한다.
- 세션 복구는 `npm run session:recover`로 시작한다.
- `workFlow.md` 실행 시 기본은 `work + session_brief` 2파일만 로드한다.
## 제품 전체 기획
화면 하나가 아니라 제품 전체 방향을 볼 때 읽는다.
- [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)
- [session_brief.md](./session_brief.md)
- [90_current_state.md](./90_current_state.md)

View File

@@ -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
View 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
- 현재 없음

View 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에서 나온다

View 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는 “실패”가 아니라 “복귀가 필요한 순간”으로 다룰 것

View 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 회고처럼 보이게 만드는 것

View 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 접근 가능

View File

@@ -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

View File

@@ -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` 안에서 관리한다.
## 파일 분리 기준

View File

@@ -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 파일에 로직 누수 여부 확인

View 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를 임의로 만들지 않았는가?

View File

@@ -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`
## 구현 중 체크

View File

@@ -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 추가
검증:

View File

@@ -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) 구현 원칙 (강제)

View 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 전반의 기획-구현 정합성을 전수 점검하는 단계다.

View 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은 그 정합성을 제품 운영의 기본 프로세스로 만들기 위한 문서다.

View 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를 이 문서에 추가한다

View 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)

View 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 코칭과 프리미엄 환경을 위해 기꺼이 지갑을 열게 될 것입니다.

View 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이 실제 세션 길이로 반영된다

View 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)

View File

@@ -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처럼 보이지 않는다

View 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)

View 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`보다 더 시끄러운 화면으로 만드는 것

View File

@@ -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`

View File

@@ -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에 결정사항을 추가해라
- 변경사항이 합리적인 단위로 커밋될 수 있도록 커밋 메시지 초안을 제안해라

View File

@@ -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
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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
View 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;
}

View File

@@ -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>
@@ -37,4 +40,4 @@ export default function LoginPage() {
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-brand-soft/20 rounded-full blur-[100px] -z-10 pointer-events-none"></div>
</div>
);
}
}

View File

@@ -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">
&copy; 2026 VibeRoom. All rights reserved.
{landing.footer.copyright}
</div>
</div>
</footer>

7
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,7 @@
'use client';
import { AdminConsoleWidget } from '@/widgets/admin-console';
export default function AdminPage() {
return <AdminConsoleWidget />;
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
export * from './model/types';
export * from './model/useAuthStore';

View 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;
}

View File

@@ -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,44 +18,39 @@ export const useAuthStore = create<AuthState>((set) => {
return {
accessToken: savedToken || null,
user: null,
isAuthenticated: !!savedToken,
isAuthenticated: Boolean(savedToken),
setAuth: (data: AuthResponse) => {
const cookieOptions = {
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, {
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, {
Cookies.set(REFRESH_TOKEN_COOKIE_KEY, data.refreshToken, {
...cookieOptions,
expires: 30
expires: 30,
});
}
// 3. 상태 업데이트
set({
accessToken: data.accessToken,
user: data.user,
isAuthenticated: true
set({
accessToken: data.accessToken,
user: data.user ?? null,
isAuthenticated: true,
});
},
logout: () => {
Cookies.remove(TOKEN_COOKIE_KEY);
Cookies.remove(REFRESH_TOKEN_COOKIE_KEY);
set({
accessToken: null,
user: null,
isAuthenticated: false
set({
accessToken: null,
user: null,
isAuthenticated: false,
});
},
};

View 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);
},
};

View File

@@ -0,0 +1,3 @@
export * from './api/focusPlanApi';
export * from './model/types';
export * from './model/useFocusPlan';

View 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;
}

View 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)),
};
};

View 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})`
);
}
},
};

View File

@@ -0,0 +1,3 @@
export * from './model/types';
export * from './model/useMediaCatalog';
export * from './model/resolveMediaAsset';

View 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,
})),
};

View 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;
});
};

View 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>;

View 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,
};
};

View File

@@ -1,2 +1,3 @@
export * from './model/mockPlan';
export * from './model/types';
export * from './model/usePlanTier';

View File

@@ -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];

View File

@@ -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;

View 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,
};
};

View File

@@ -1,2 +0,0 @@
export * from './model/rooms';
export * from './model/types';

View File

@@ -0,0 +1,2 @@
export * from './model/scenes';
export * from './model/types';

View File

@@ -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,
};
};

View File

@@ -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;
}

View 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,
});
},
};

View File

@@ -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];

View File

@@ -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;
}

View File

@@ -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')
);
});
} catch {
return [];
}
};
const persistThoughts = (thoughts: RecentThought[]) => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(THOUGHT_INBOX_STORAGE_KEY, JSON.stringify(thoughts));
};
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,
});
export const useThoughtInbox = () => {
const [thoughts, setThoughts] = useState<RecentThought[]>(() => readStoredThoughts());
const [thoughts, setThoughts] = useState<RecentThought[]>([]);
useEffect(() => {
persistThoughts(thoughts);
}, [thoughts]);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== THOUGHT_INBOX_STORAGE_KEY) {
return;
}
setThoughts(readStoredThoughts());
};
window.addEventListener('storage', handleStorage);
let mounted = true;
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;
}, []);

View 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,
);
},
};

View File

@@ -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,
});
},
};

View 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>
);
}

View 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>
);
}

View File

@@ -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 연동) */}

View File

@@ -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);
}
};

View File

@@ -0,0 +1,2 @@
export const TOKEN_COOKIE_KEY = "vr_access_token";
export const REFRESH_TOKEN_COOKIE_KEY = "vr_refresh_token";

View 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));
};

View File

@@ -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';

View File

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

View File

@@ -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,
};
};

View File

@@ -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>
);
};

View 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,
});
},
};

View File

@@ -0,0 +1,2 @@
export * from './api/focusSessionApi';
export * from './model/useFocusSessionEngine';

View 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),
};
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
};

View 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),
});
},
};

View File

@@ -0,0 +1,2 @@
export * from './api/preferencesApi';
export * from './model/useUserFocusPreferences';

View 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,
};
};

View File

@@ -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;

View File

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

View 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>
);
};

View File

@@ -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">

View File

@@ -1,2 +1,3 @@
export * from './model/useSoundPlayback';
export * from './model/useSoundPresetSelection';
export * from './ui/SoundPresetControls';

View 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,
};
};

View File

@@ -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>

View File

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

View File

@@ -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>
);
};

View 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',
});
},
};

View File

@@ -0,0 +1,2 @@
export * from './api/statsApi';
export * from './model/useFocusStats';

View 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,
};
};

View 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