데스크탑 캘린더에 생명을 불어넣다
세 가지 뷰에 걸친 드래그앤드롭, 커서를 따라가는 가이드 라인, 낙관적 업데이트, 그리고 가독성을 바꾼 디자인 전환.
데스크탑 캘린더에 생명을 불어넣다
Fecit 데스크탑 캘린더에는 기본기가 있었습니다. 월간 뷰, 주간 뷰, 태스크 바. 하지만 일정의 사진이었지, 작업할 수 있는 일정은 아니었어요. 손을 뻗어 안으로 들어가고 싶었습니다. 무언가를 옮기고, 하루의 형태가 손 아래에서 바뀌는 것을 느끼고 싶었어요.
모든 것을 망치는 1픽셀
처음 신경 쓰인 건 태스크 블록이었습니다. 각 블록이 포인트 컬러로 채워진 직사각형이었거든요. 괜찮아 보였습니다 — 그렇지 않을 때까지는. Midnight blue에 흰 텍스트? 읽을 만합니다. Lemon yellow에 흰 텍스트? 사라집니다. 모든 색상에 별도의 텍스트 처리가 필요했고, 대비가 기술적으로 충분하더라도 시각적 무게감이 일관되지 않았습니다. 서로 다른 밝기의 컬러 블록들이 벽을 이루면, 통일된 인터페이스가 아니라 소음으로 읽힙니다.
모바일 앱에는 이미 답이 있었습니다. 중립적인 밝은 배경에 왼쪽 가장자리의 얇은 4px 바가 색상을 담당합니다. 별 아이콘이 같은 색을 받고, 타이틀은 항상 어둡고, 시간은 항상 회색이에요. 스무 가지 포인트 컬러 모두에서 작동합니다. 예외 없이, 조건 분기 없이.
데스크탑에 이식하는 데 10분이 걸렸습니다. 가독성 개선은 즉각적이고 극적이었어요. 하지만 블록과 캘린더 배경 사이의 경계를 맞추는 데는 더 오래 걸렸습니다. 블록이 NEUTRAL50을 쓰고 캘린더 배경도 NEUTRAL50이거든요. 동일합니다. 블록이 그리드 속으로 사라졌어요. 흰색 배경을 시도했지만 다크 테마가 깨집니다. 결국 1px solid NEUTRAL300 테두리와 미세한 box-shadow를 넣었습니다. 그림자는 rgba(0,0,0,0.06) — 거의 보이지 않지만, 블록을 표면에서 들어올리기에는 충분합니다. 빼면 블록이 페이지에 인쇄된 것처럼 보이고, 더 넣으면 2018년식 카드 UI가 됩니다. 정확히 맞는 양은 하나뿐이에요.
아무도 요청하지 않은 Day View
데스크탑에는 일간 뷰가 없었습니다. 모달 오버레이에서 그 날의 태스크 목록을 볼 수는 있었지만, 하루의 형태를 보는 방법은 없었어요 — 오전이 꽉 차 있고 오후가 비어 있다는 것을, 회의 사이에 두 시간 여유가 있다는 것을 한눈에 파악할 방법이요.
CalendarDayView를 풀 테이크오버로 만들었습니다. 월간 뷰에서 날짜 셀에 호버하면 우상단에 maximize 아이콘이 나타납니다. 클릭하면 월간 뷰가 물러나고 하루 안으로 들어가요. 24시간, 세로 타임라인, 새로운 좌측 보더 디자인의 태스크 블록.
뒤로 버튼은 텍스트 라벨이 아니라 제대로 된 back.svg 아이콘을 씁니다. 날짜가 중앙에 좌우 화살표와 함께 있고, 오른쪽에 생성/제네레이트 버튼이 있어요. 단순해 보이지만 레이아웃은 세 번의 반복을 거쳤습니다. 첫 번째: 뒤로 왼쪽, 날짜 왼쪽, 화살표 오른쪽. 왼쪽이 너무 무거웠어요. 두 번째: 뒤로 왼쪽, 날짜 중앙, 화살표 중앙, 닫기 오른쪽. 닫기 버튼이 뒤로 버튼 옆에서 중복으로 느껴졌습니다. 최종: 뒤로 왼쪽, 날짜+화살표 중앙, 액션 버튼 오른쪽. 비로소 균형이 잡혔어요.
하마터면 빠뜨릴 뻔한 디테일이 있습니다: 스크롤 위치 보존. 47주차까지 내려가서 하루를 클릭하고, 이리저리 둘러보고, 뒤로 가면 — 캘린더 맨 위에 있습니다. 모든 맥락이 사라진 거예요. 수정은 일간 뷰 진입 시 scrollTop을 ref에 저장하고, 복귀 시 useLayoutEffect에서 복원하는 것이었습니다. 이것 없이도 기능은 기술적으로 완성입니다. 하지만 실제 사용에서는 짜증나요. 사용자가 절대 칭찬하지 않지만, 없으면 즉시 알아차리는 종류의 것입니다.
가이드 라인: 추측과 앎의 차이
주간 뷰에는 이미 드래그앤드롭이 있었습니다. 블록을 롱프레스해서 옮길 수 있었어요. 하지만 드래그 중에는 추측이었습니다. 여기가 정확히 어디에 놓이지? 이 세로 위치가 몇 시에 해당하지?
모바일에서는 가이드 라인이 이걸 해결합니다. 두 수평선 — 하나는 예상 상단, 하나는 예상 하단 — 이 타임라인을 가로지릅니다. 작은 필이 “09:30 – 10:45”를 보여주고요. 놓기 전에 알 수 있습니다.
데스크탑에도 같은 것을 추가한 후, 툴팁에 비합리적인 시간을 보냈습니다.
첫 번째 문제: 툴팁 텍스트가 필 안에서 세로 중앙이 아니었습니다. fontSize: 10에서 기본 line-height는 대략 1.4이고, 여분의 공간이 불균등하게 분배됩니다 — 베이스라인 위가 아래보다 많아요. 숫자 “0”이 살짝 위에 떠 있는 것처럼 보였습니다. lineHeight: 1로 도움이 됐지만, paddingBlock: 2는 상단 간격이 하단보다 여전히 커 보이게 했어요. 폰트의 어센더가 디센더보다 크기 때문에 동일한 패딩이 동일하게 보이지 않습니다. 최종 값: paddingTop: 1, paddingBottom: 1, lineHeight: 1. 텍스트가 눈이 기대하는 자리에 놓입니다. 2배 줌에서 확인했어요.
두 번째 문제: 가이드 라인이 타임라인 컬럼 경계에서 멈췄습니다. 왼쪽 시간 라벨 영역까지 이어지지 않았어요. 가이드 라인과 시간 라벨이 단절된 시각적 공간에 존재했습니다. 라인을 left: 0 — 컨테이너 전체 너비 — 으로 확장하니 모든 것이 연결된 느낌이 됐어요. 툴팁은 left: 4로 옮겨 시간 라벨 영역에 자리 잡았습니다. 작은 변화지만, 전체 드래그 경험을 통일했어요.
세 번째 문제: font-variant-numeric: tabular-nums. 이것 없이 드래그하면 “09” → “10”을 지날 때 툴팁 너비가 떨립니다. 프로포셔널 폰트에서 자릿수 너비가 다르기 때문이에요. 테뷸러 넘스로 모든 숫자가 같은 너비를 차지합니다. 툴팁이 가만히 있어요. 있을 때는 알아차리지 못하지만, 없을 때의 불안정함은 느낍니다.
한 시간을 태운 버그
드래그앤드롭을 구현한 후, 작동하지 않았습니다. 블록을 드래그하고 놓으면 아무 일도 안 일어났어요. 블록이 원래 자리로 돌아갔습니다.
원인은 오래된 클로저였습니다. handleUp이 useEffect 안에서 mouseup 리스너로 등록됐고, 이펙트 실행 시점의 dragDelta state 값을 캡처했어요 — 드래그 시작 시점이라 0이었습니다. 이후 mousemove마다 setState로 dragDelta가 업데이트됐지만, handleUp 클로저는 여전히 0을 들고 있었어요.
수정: useRef. 매 move마다 dragDeltaRef.current에 최신 값을 저장하고, handleUp에서 ref를 읽습니다. React에서 잘 알려진 패턴이지만, 한 시간을 잃어봐야 배우는 종류의 것이에요. 오늘 서로 다른 세 드래그 구현에서 같은 버그를 만났고, 매번 같은 방식으로 고쳤습니다.
관련 이슈도 있었습니다: 드래그 후 클릭 이벤트가 발생해요. mouseup → click. 블록의 onClick이 상세 모달을 엽니다. 모든 드래그가 원치 않는 모달로 끝났어요. 수정은 requestAnimationFrame이었습니다: 드래그 중 didDragRef.current = true를 설정하고, onClick에서 확인하고, handleUp의 requestAnimationFrame 콜백에서 리셋합니다. rAF가 리셋을 클릭 이벤트 이후로 보장해요. 코드 리뷰에서는 보이지 않지만, 사용하는 순간 즉시 드러나는 종류의 타이밍 버그입니다.
깜빡임
드래그가 작동했지만, 시각적 결함이 있었습니다. 마우스를 놓으면 블록이 한 프레임 동안 원래 위치로 돌아갔다가, API 응답이 오면 새 위치로 점프했어요. 한 프레임. 어쩌면 16밀리초. 하지만 보입니다. 앱이 방금 일어난 일에 대해 확신이 없는 것처럼 느껴져요.
낙관적 업데이트가 이걸 고칩니다. mouseup 시점에 API 호출 전에 태스크 객체를 새 날짜로 복제해서 로컬 상태를 즉시 업데이트해요. 마우스를 놓는 프레임에 블록이 목표 위치로 이동합니다. API가 응답하면 서버의 데이터로 상태를 다시 업데이트하고요 — 보통 동일합니다. API가 실패하면 원래로 되돌립니다.
이제 이 패턴을 모든 곳에 적용하고 있습니다. 모든 드래그, 모든 상태 변경, 모든 날짜 수정. 사용자가 앱이 기다리는 것을 보면 안 됩니다. 서버가 느리면 UI는 이미 넘어가 있고, 서버가 실패하면 UI가 우아하게 롤백합니다. 앱이 “생각 중인” 중간 상태는 보여서는 안 돼요.
월간 뷰: 다른 종류의 드래그
타임라인 드래그는 시간을 따라 블록을 이동합니다. 월간 뷰 드래그는 날짜 사이를 이동해요. 다른 기하학, 다른 수학, 같은 사용자 기대: 집어서, 다른 데 놓는다.
태스크 바를 롱프레스(150ms)하면 시작됩니다. 고스트가 커서를 따라요 — 태스크의 포인트 컬러, opacity: 0.5, border: 2px dashed. 처음에는 원본 DOM 요소를 클론해서 고스트로 썼지만, 포기했습니다. 클론된 노드는 자식 요소, 이벤트 리스너, 복잡한 CSS를 그대로 가져옵니다. 점선 테두리의 단순한 컬러 직사각형이 같은 것을 전달하면서 부담은 없어요.
커서 위치를 날짜로 매핑하는 건 이렇습니다: 스크롤 컨테이너가 주 행을 190px씩 담고 있고, 7컬럼이에요. clientX로 컬럼(요일)을, clientY + scrollTop을 행 높이로 나눠 행(주차)을 구합니다. 컬럼 + 행에서 대상 날짜를 도출하고요. 마우스를 놓으면 원점과 대상의 날짜 차이를 계산하고, 태스크의 시작/종료 날짜를 그만큼 이동합니다. 기간은 보존돼요 — 3시간짜리 태스크는 3시간으로 유지되고, 날짜만 바뀝니다.
캘린더 블록의 상태 버튼
모바일에서는 모든 태스크에 상태 토글이 있습니다: 빈 원(등록), 애니메이션 점(시작), 채워진 체크(완료). 탭으로 순환해요. 데스크탑 캘린더 블록에도 이게 필요했습니다.
TaskRecordItem의 로직을 독립적인 TaskStateButton으로 추출했습니다. 같은 상태 머신, 같은 축하 애니메이션, 같은 격려 말풍선이에요. 하지만 말풍선 위치에 작업이 필요했습니다.
상태 버튼은 태스크 블록의 오른쪽 끝에 있습니다. 격려 말풍선은 왼쪽 끝의 별 아이콘 위에 나타나야 해요. TaskStateButton 안에서 완료가 말풍선을 트리거하면, 버튼에서 DOM을 위로 탐색해 [data-star] 속성을 찾습니다 — 세 뷰 모두의 모든 별 아이콘에 추가해둔 것이에요. 별의 getBoundingClientRect()가 정확한 위치를 줍니다. 별을 못 찾으면 버튼 자체 위치로 폴백하고요.
말풍선 자체도 다듬었습니다. 일시적인 알림치고 글자가 너무 컸어요: 14/13/11에서 12/11/10으로 줄였습니다. 패딩은 드래그 툴팁과 같은 어센더/디센더 문제로 시각적으로 불균등했어요 — 광학적 중앙 정렬을 위해 paddingTop: 1, paddingBottom: 3. 별과 말풍선 사이 간격은 0px에서 너무 빡빡해서 8px 오프셋을 추가했습니다. “정답” 값이 없는 종류의 조정이에요 — 더 이상 신경 쓰이지 않을 때까지 계속 보는 수밖에 없습니다.
칩 태그 문제
태스크 생성 모달의 템플릿 제안은 <chip key="..." value="..."/> 태그를 포함할 수 있는 타이틀을 보여줍니다. 모바일에서는 네이티브 ChipTextInput 모듈이 이것을 스타일된 필로 렌더링해요. 데스크탑(웹)에서는 그 네이티브 모듈이 없습니다. 드롭다운에 원본 XML이 그대로 노출되고 있었어요: Buy <chip key="grocery" value="groceries"/> for dinner.
수정은 정규식 파서였습니다. 타이틀 문자열을 텍스트 구간과 칩 구간으로 나누고, 칩을 PRIMARY100 배경에 PRIMARY700 텍스트의 인라인 <span>으로 렌더링합니다. 모바일 칩과 같은 시각 언어예요. 네이티브 모듈의 완전한 구현은 아닙니다 — 편집, 삭제, 키보드 인터랙션은 없어요 — 하지만 드롭다운의 읽기 전용 표시에는 정확히 필요한 것입니다. 그 이상도 이하도 아닌.
마무리
오늘 작업의 2시간짜리 버전이 있습니다: 드래그 핸들러 추가, 드롭 시 API 호출, 다음으로. 작동할 것이고, 테스트도 통과할 것이고, 기능은 “완료”일 겁니다.
하지만 가이드 라인, 툴팁 중앙 정렬, 커서를 따르는 고스트, 보존되는 스크롤 위치, 낙관적 업데이트, 별 아이콘을 찾아가는 격려 말풍선 — 이것들은 기능이 아닙니다. 작동하는 소프트웨어와 실제로 쓰고 싶은 소프트웨어의 차이예요. 혼자 만들고 있으니 “됐다, 넘어가자”라고 말해줄 사람이 없습니다. 저주이자 선물이에요. 오늘 툴팁 패딩 1px에 20분을 썼습니다. 아무도 시키지 않았어요. 하지만 그게 틀렸을 때 어떻게 보이는지 알고, 한번 보이면 안 볼 수가 없습니다.