진행 중을 어떻게 보여줄까 — 알람부터 끝까지
focus가 파란 외곽 wrap이라면, '진행 중'은 어떻게 표시할까. STARTED 한 줄로 끝날 줄 알았는데, 등장 시점이 어디인지부터 한참을 이야기한 하루.
진행 중을 어떻게 보여줄까
Fecit의 task 항목에는 이미 표지자가 몇 개 있습니다. 별의 색 변화로 fidelity를, 카드 우측 노란 줄로 회고 작성 여부를, 외곽 파란 wrap으로 focus를. 각각 다른 의미를 다른 자리에 둡니다.
오늘은 거기에 하나 더 — 지금 진행 중인 일정을 시각적으로 구분하는 표지자가 필요했어요. focus와 비슷한 외곽 wrap 형태인데, 색은 빨강. 처음엔 단순할 줄 알았습니다.
”진행 중”의 정의
먼저 진행 중이 뭔지부터 정리해야 했습니다.
처음에 떠올린 정의는 STARTED 상태였습니다. 사용자가 “시작” 버튼을 눌러 상태가 STARTED로 바뀐 task. 깔끔하지만 사용 시나리오를 따져 보니 어색했어요.
캘린더에 일정을 잡아 둔 상황을 생각해 봅니다. 10시에 회의가 있어요. 9시 55분에 알람이 울리고, 사용자가 책상으로 돌아옵니다. 10시 정각이 되었는데 사용자는 “시작” 버튼을 따로 누르지 않아요 — 일정이 있는 task는 보통 시작 시간이 되면 자연스럽게 진행되는 거지, 매번 버튼을 누르지 않습니다.
이 시간 동안 진행 중 표지자가 떠 있어야 합니다. STARTED 상태가 아닌데도요.
시작점은 알람 시간
그래서 트리거를 시간으로 잡았습니다. 일정의 시작 시간을 기준으로 표지자가 등장.
그런데 한 단계 더 미루는 게 자연스러웠어요. 알람 시간 — startDate에서 reminderOffset만큼 앞당겨진 시간. 알람이 울린 순간부터 표지자가 떠 있으면, 사용자는 “지금부터 그 일정 시간이야” 라고 시각적으로 인지하게 됩니다.
const inProgressStart = useMemo(() => {
if (!task.startDate) return undefined;
const offsetMin = task.reminderOffset ?? 0;
return new Date(task.startDate.getTime() - offsetMin * 60_000);
}, [task.startDate, task.reminderOffset]);
reminderOffset이 안 잡혀 있으면 startDate를 그대로 씁니다. “알람이 없으면 시작 시간부터”. 알람이 없다고 표지자를 안 띄우는 건 부자연스러워요.
끝나는 지점
언제 사라질까. 처음엔 완료/취소되면 사라짐이라고만 정했는데, 곧 “시간이 다 되면 사라지는 거 아니야?” 라는 자각이 왔습니다. 맞습니다 — 일정의 endDate가 지나면 그 일정은 더 이상 지금 진행 중이 아니에요. 사용자가 늦게까지 손을 못 댔든 어쨌든, 표지자의 의미는 “이 시간 안” 이지 “이 사람이 끝낼 때까지” 가 아닙니다.
최종 조건:
- 등장:
now >= alarmTime - 사라짐:
now > endDate또는 COMPLETED/CANCELLED - 전제: startDate와 endDate가 둘 다 있을 것 (없으면 언제까지가 정의되지 않으니)
매 분 재평가가 필요해서 nowMinuteAtom (매 분 갱신되는 jotai atom)을 구독합니다. 분 단위 정확도면 충분해요.
focus가 우선이다
여기까지 만들고 보니 한 task에 표지자 두 개가 겹치는 케이스가 나왔습니다. focus(파란 외곽)와 진행 중(빨간 외곽)이 같이 떠 있는 task.
focus를 했다는 건 사용자가 지금 이 일을 집중해서 하겠다는 의지의 표현이고, 진행 중은 일정 시간 안에 있다는 사실의 표현입니다. 둘 다 active하지만 의미 층위가 다릅니다. 시각적으로 같이 두면 어느 게 우선인지 헷갈리고, 색만 두 줄 겹쳐 보입니다.
규칙: focus가 떠 있으면 빨간 표지자는 생략. focus는 사용자의 능동적 결정이고 빨간 표지자는 자동 시간 기반이니, 능동을 우선합니다.
const isInProgress = useMemo(() => {
if (isFocused) return false; // focus 표지자가 우선
if (displayState === COMPLETED || displayState === CANCELLED) return false;
if (!inProgressStart || !task.endDate) return false;
return now >= inProgressStart && now <= task.endDate;
}, [...]);
모바일과 데스크탑 동시
같은 규칙을 두 플랫폼에 동시에 깔았습니다. 모바일은 RN의 absoluteFillObject 오버레이로, 데스크탑은 Tailwind의 absolute inset-0으로 — 구현 디테일은 다르지만 시각적으로 동일한 외곽 1.5px DANGER500 테두리.
reminder_at 같은 계산 필드는 API 쪽에도 이미 있어서, 클라이언트의 알람 시간 계산이 서버와 어긋나지 않는지도 확인했습니다. 둘 다 같은 공식 — startDate - reminderOffset minutes, offset 없으면 0. 결정적이라 drift 없음.
회고
“지금 진행 중”이라는 단순해 보이는 한 줄이, 막상 정의하려고 들어가 보면 상태 기반인가, 시간 기반인가, 시간 기반이라면 어느 시점부터인가, 어디서 끝나는가, 다른 표지자와 겹치면 어떻게 되는가 — 적어도 다섯 갈래로 갈라집니다.
각 갈래에서 작은 결정을 내릴 때마다 Fecit이 어떤 앱인지가 한 조각씩 드러나요. 알람을 등장점으로 잡은 건 “예약된 시간이 곧 진행 시간” 이라는 캘린더 철학에서 나왔고, focus를 우선시한 건 “자동 트리거보다 사용자의 능동적 선택이 위” 라는 원칙에서 나왔습니다.
표지자 하나 색만 정하는 줄 알았는데, 끝나고 보니 의도와 자동, 시간과 상태 사이에서 짧게 줄긋기를 한 작업이었어요.