집중 바가 25분을 세기 시작했다
이미 있던 한 칸에 정식 뽀모도로를 얹으며 한 결정들 — 표준 사이클을 그대로 둔 이유, setInterval을 안 쓴 이유, 그리고 포커스가 풀리면 같이 풀려야 한다는 결.
집중 바가 25분을 세기 시작했다
Fecit에는 이미 집중 바가 있습니다. 사용자가 “지금 이 일에 집중한다”고 선택하면 할 일 목록 위에 가로로 떠 있는 작은 PRIMARY 색 줄. 그 아래에 포커스된 태스크 카드가 따로 슬롯을 갖고 보입니다. 시간이라는 차원은 거기 없었습니다 — 그저 어떤 일을 골랐는지를 표시할 뿐이었죠.
오늘은 그 바에 모래시계 아이콘 하나를 더했습니다. 누르면 정식 뽀모도로가 돌아가기 시작합니다.
정식 사이클을 그대로 두기로 했다
처음 결정해야 했던 건 어떤 뽀모도로를 만들지였습니다.
옵션은 셋이었습니다. (1) 25분 단발 — 한 번 누르면 25분이 흐르고 끝. (2) 25/5 한 세트만. (3) 25/5 × 4 + 15분 긴 휴식, Cirillo의 원래 사이클. 단순할수록 만들기는 쉽지만, “뽀모도로”라는 이름이 30년 동안 쌓아 둔 기대치를 깎는 셈입니다. 사용자가 뽀모도로를 누르면서 “25분만 돌면 끝나겠지”를 기대할지, “한 사이클 다 도는 거겠지”를 기대할지 — 후자가 더 자연스럽다고 봤습니다.
세 번째로 갔습니다. work 25 / shortBreak 5 / 네 번째엔 longBreak 15. 자동 전환. 사용자는 “시작”만 누르면 됩니다.
기본을 어디에 두느냐의 문제이기도 합니다. 커스텀 분 단위로 슬라이더를 보여주는 앱도 많지만, 그건 결정해야 할 게 더 많아진다는 뜻이기도 합니다. 정식 사이클은 30년 검증된 기본값이고, 사용자가 그 위에 굳이 손댈 필요가 없습니다.
setInterval로 매초 빼주지 않는다
뽀모도로 타이머의 흔한 구현은 setInterval(1000)을 걸어 두고 매초 1을 빼는 식입니다. 단순하지만 두 가지 약점이 있습니다 — 백그라운드로 가면 멈춤(브라우저/모바일이 다 그렇진 않지만 대개 부정확해짐), 그리고 사용자가 자리를 비웠다 돌아왔을 때 그 사이 시간을 어떻게 처리할지가 애매해집니다.
대신 startedAt: number 하나만 저장하고, 매 렌더에서 지금 시각에서 빼서 남은 시간을 계산하기로 했습니다. nowSecondAtom이 이미 1초 tick을 굴리고 있어서 거기 묻혀 가면 됩니다. setInterval은 하나도 더 안 만듭니다.
const elapsed = paused
? elapsedBeforePauseMs
: elapsedBeforePauseMs + (Date.now() - startedAt);
const remaining = phaseDuration - elapsed;
phase 전환은 별도 effect에서 remaining <= 0 인지를 매 tick 확인합니다.
자리를 비웠다 돌아오면 한 번에 점프한다
타임스탬프 기반으로 가니까 자연스럽게 따라오는 게 하나 있었습니다. 사용자가 30분 자리를 비웠다가 돌아오면 어떻게 될 것인가.
원래 사이클로 치면 그 사이에 work 25 → shortBreak 5 → work 5분 진행, 이런 식이 됐어야 합니다. 가장 단순한 구현은 매 tick에서 phase 하나씩 advance — 그러면 복귀 시점에 1초에 한 phase씩, 약간 어색하게 따라잡습니다.
루프를 짧게 돌렸습니다. advanceAll. tick 안에서 remaining < 0이면 다음 phase로 옮기고, 그 phase의 시작 시각을 이전 phase가 종료된 시각으로 잡습니다. 그러면 overflow된 시간이 다음 phase의 elapsed로 자연스럽게 넘어갑니다. 16번까지 점프 가능하게 두고 (한 세트가 8 phase + 안전 마진), 한 번의 effect 안에서 최종 위치까지 갑니다.
복귀 시 토스트와 햅틱은 마지막 전환 하나만 울립니다. 누락된 phase 일곱 개의 알림이 한꺼번에 폭격하지 않습니다.
포커스가 풀리면 같이 풀려야 한다
뽀모도로 UI를 집중 바 안에 얹은 게 잘한 결정이었던 이유가 한 가지 더 있었습니다 — 그 자리에 두지 않았다면 발견 못 했을 결합이 보였습니다.
집중 바는 사용자가 포커스를 풀면 사라집니다(포커스 슬롯 자체가 안 그려짐). 그런데 뽀모도로 상태는 별도 atom에 있으니까, 포커스가 풀려도 타이머는 계속 돌고 있는 유령 상태가 가능합니다. 사용자는 화면에서 카운트다운을 못 보고, 모달을 여는 진입점(모래시계 아이콘)도 사라져 있고, 그런데 25분 후에 비프와 토스트는 울립니다. 어디서 울린 건지 모르는 채로요.
usePomodoroFocusGuard라는 작은 훅을 root에 마운트했습니다. achiever.focusedTaskRecordId가 비어 있으면 pomodoroAtom을 null로, 모달도 닫음. 단순합니다. 하지만 이걸 안 넣었으면 사용자가 가끔 황당한 비프를 듣는 앱이 됐을 겁니다.
자동 unfocus 옵션과도 자연스럽게 맞물립니다. 포커스된 일이 완료되면(autoUnfocusOnComplete = true 기본값) 포커스가 풀리고, 그러면 뽀모도로도 자동으로 멈춥니다. 한 가지 의도(이 일이 끝났다)가 두 화면에 동시에 반영됩니다.
모바일은 햅틱, 데스크탑은 비프
phase 전환과 세트 완료 시점에 무엇으로 알리는가도 결정해야 했습니다.
데스크탑은 비교적 간단했습니다. Web Audio API로 880Hz 사인파 3번을 만들어 띄움. 외부 음원 파일 안 둬도 됨.
모바일은 더 결이 복잡합니다. 가장 정직한 선택은 expo-notifications로 local notification을 예약하는 거였습니다 — 앱이 백그라운드/잠금 상태여도 울립니다. 그런데 권한 요청이 필요하고, 사용자가 거부했을 때 fallback이 또 필요하고, “뽀모도로 한 번 하려고 알림 권한 달라는 거?”라는 마찰도 생깁니다.
작게 가기로 했습니다. 포그라운드 한정, expo-haptics의 NotificationFeedbackType.Success + 토스트. 백그라운드에서는 안 울립니다. 사용자가 일하면서 앱을 보고 있을 때만 작동하는 게 사실 뽀모도로의 사용 패턴이기도 합니다.
집중 바에 카운트다운이 mm:ss로 떠 있어서, 모달을 닫아도 시각적인 진행은 보입니다. 이 정도면 알림이 약해도 사용자가 잃어버리지 않습니다.
별이 춤추는 방식이 달라야 했다
뽀모도로 자체와 직접 관련은 없는데, 작업 중에 한 디테일이 떠올랐습니다.
태스크가 신규 생성될 때는 별이 bounce(playHop — 위로 4px 튀어오르며 spring) 합니다. 한 번 결정해 두면 다른 데에도 동일하게 쓰고 싶어집니다. 메모도 같은 시기에 NewListItemIndicator 효과를 받게 됐는데, 별이 bounce하면 태스크랑 동일해서 구분이 안 됩니다.
별도 함수 하나를 더 추가했습니다 — swirl. playSwirl: 360° 회전 + scale 1.18 pop, 500ms. bounce가 “위로 튀어오르는” 느낌이라면, swirl은 “한 바퀴 가볍게 돌고 자리잡는” 느낌입니다. 메모는 좀 더 조용한 신호물(태스크는 행동의 단위, 메모는 생각의 단위)이라 회전이 더 어울립니다.
태스크 = bounce, 메모 = swirl, 템플릿 = dignified(정중한 360° 회전 + scale). 같은 라이브러리에서 셋 다 표현이 다르게 떨어지게 둡니다.
모래시계는 사라지지만 진행 표시는 남는다
집중 바 우측 끝 모래시계 아이콘은 뽀모도로가 안 돌 때만 보입니다. 사용자가 시작하면 그 자리에서 mm:ss 카운트다운과 phase 아이콘(망치/커피)으로 전환됩니다. 사라지지 않고 다른 모습으로 바뀌는 것 — 같은 자리에 두는 게 사용자한테 “어디 갔지?”를 안 만들기 때문입니다.
phase 아이콘도 결정이 있었습니다. work는 처음에 펜슬로 했다가 망치로 바꿨고, 망치 SVG를 직접 그리려다 인식이 안 돼서 Lucide 망치를 들여왔습니다(MIT, 24×24 stroke 2px). 작은 사이즈(14px)에서 stroke-only 아이콘의 식별성은 작가의 의도보다 라이브러리의 검증된 형태가 거의 항상 이깁니다.
모달은 모바일에서 바텀시트로
데스크탑은 중앙 정렬 모달이 자연스럽지만, 모바일은 다른 모달들이 대부분 바텀시트 패턴입니다. PomodoroModal도 처음엔 RN <Modal>을 transparent 중앙 정렬로 만들었다가, 다른 모달과 톤이 맞도록 BottomSheetModal로 바꿨습니다.
그 와중에 본 작은 iOS 이슈 하나 — <Modal transparent> 위에 Portal로 띄운 dim이 가려지는 케이스가 있었습니다. iOS RN Modal이 별도 window라 portal 콘텐츠가 그 뒤에 깔립니다. 해결: dim을 Modal 안쪽의 outer View에 통합. portal 의존성을 제거하고 더 단순해졌습니다.
집중 바는 “지금 이 일을 골랐다”는 신호였습니다. 거기 25분이 더해지면서 “지금 이 일에 25분을 약속한다”가 됩니다. 신호가 한 겹 두꺼워졌습니다 — 작은 변화인데, 자기 자신과의 약속을 더 분명하게 만들어 주는 변화이기도 합니다.
5분짜리 일도 25분 안에 자리잡지 못하면 자기 자신에게 약속을 어긴 거라는 걸 알게 됩니다. 그게 25분이라는 사이클의 진짜 효과인 것 같습니다.