
10편에서 가족 대시보드와 주간 목표, 브라우저 알림까지 달았습니다. 하지만 리뷰 페이지를 열 때마다 뭔가 아쉬웠습니다. 달성률 숫자 하나만 덜렁 있으니, "이번 주 가장 많이 한 게 뭔지", "계획이 얼마나 흔들렸는지", "달성률이 올라가고 있는지" 같은 질문에 답이 안 됐거든요. 그리고 타임그리드에서 "원래 달리기였는데 회의가 끼었다"를 표현할 방법도 없었습니다. 이번 편에서는 리뷰 대시보드를 3가지로 강화하고, Plan 레이어를 보존하는 UX를 만든 과정을 정리합니다.
1. 환경 정보
| 항목 | 버전/스펙 |
|---|---|
| Backend | PocketBase (Self-hosted, SQLite) |
| Frontend | HTML + Vanilla JS (PWA) |
| Infra | Proxmox VE — LXC 컨테이너 |
| Web Server | Nginx (리버스 프록시) |
| SSL | Let's Encrypt |
2. 배경 — 리뷰 페이지에 부족했던 3가지
기존 리뷰 페이지에는 계획 시간, 실행 시간, 달성률 정도만 있었습니다. 쓰다 보니 세 가지가 계속 궁금했습니다.
- "가장 많이 한 일이 뭐지?" — 이번 주에 내 시간이 어디에 가장 많이 갔는지 보고 싶었습니다.
- "계획이 얼마나 바뀌었지?" — 계획대로 한 비율, 다른 일을 한 비율, 아예 못 한 비율을 나눠서 보고 싶었습니다.
- "달성률이 올라가고 있는 건가?" — 주 단위 추이를 선 그래프로 보면 동기부여가 될 것 같았습니다.
그리고 타임그리드 쪽에도 문제가 있었습니다. "4시부터 달리기였는데 급한 회의가 들어왔다"는 상황에서 Plan을 덮어쓰면 원래 계획이 사라지고, Done만 바꾸려면 2단계 조작이 필요했습니다.
3. 리뷰 대시보드 강화
3-1. 가장 많이 한 일 TOP5
주간 Done 데이터를 라벨별로 집계해서 시간 순으로 랭킹하는 기능입니다.
삽질: 색상 기반 → 라벨 기반
처음 구현은 pc-0, pc-1 같은 색상 인덱스별 합산이었습니다. 문제는 같은 색(pc-0)에 "달리기"와 "블로그"와 "점심"이 배정될 수 있다는 것이었습니다. 전부 합쳐져서 "달리기 5시간 30분"이라는 말도 안 되는 수치가 나왔습니다.
해결 방법은 payload의 slot-label 텍스트를 블록 단위로 추적하는 것이었습니다. 라벨별로 Plan/Done 시간을 집계하도록 수정했습니다.
// 라벨 추적: 새 라벨이 나오면 갱신, 색상이 바뀌면 리셋
if (slotLabel) curLabel = slotLabel;
if (planCI !== curPlanCI && planCI >= 0) {
curPlanCI = planCI;
if (!slotLabel) curLabel = '';
}
if (hasPlan && curLabel) {
result.planLabels[curLabel] = (result.planLabels[curLabel] || 0) + 1;
}

핵심은 curLabel 변수입니다. 슬롯에 라벨이 있으면 갱신하고, 색상이 바뀌면 리셋합니다. 이렇게 하면 같은 색이어도 라벨이 다르면 별도로 집계됩니다.
3-2. 고정 항목 필터링
취침, 출근, 퇴근, 점심, 저녁 같은 매일 반복 항목이 TOP5를 차지하면 의미가 없습니다. isFixed() 함수로 필터링했습니다.
var FIXED_ITEMS = ['취침','출근','퇴근','점심','저녁',
'🏢 출근','🍚 점심','🍽️ 저녁','🏠 퇴근','😴 취침'];
function isFixed(name) {
return FIXED_ITEMS.some(f => name.indexOf(f) >= 0);
}
이모지가 붙은 변형도 커버해야 해서 indexOf로 부분 매칭합니다. 정규식 대신 indexOf를 쓴 이유는 이모지가 포함된 문자열에서 정규식이 예상 외로 동작하는 경우가 있어서, 단순 부분 매칭이 더 안정적이었습니다.
3-3. 계획 변경률
Plan과 Done을 슬롯별로 비교해서 세 가지로 분류합니다.
- 계획대로 완료 (matched):
done-marked+ 같은 색상 → 초록 - 다른 일 수행 (changed):
done-outlined→ 노랑 - 미완료 (missed): Plan만 있고 Done 없음 → 빨강
3개 카드로 비율을 보여주고, 일별 스택 바로 각 날짜의 변경률을 시각화했습니다. 한눈에 "수요일에 계획이 많이 흔들렸구나" 같은 패턴이 보입니다.

이 지표가 생기면서 리뷰 때 "왜 변경이 많았지?"를 자연스럽게 돌아보게 됩니다. 단순 달성률보다 훨씬 유용했습니다.
3-4. 월간 달성률 추이 — SVG 꺾은선 그래프
최근 8주의 달성률을 SVG 꺾은선 그래프로 그립니다. 차트 라이브러리 없이 <line>, <path>, <circle>, <text>만으로 구현했습니다.
각 점의 색상은 달성률 구간에 따라 달라집니다.
- 80% 이상: 초록
- 50~79%: 노랑
- 49% 이하: 빨강

데이터 로드가 좀 무겁습니다. 주 7일 × 8주 = 56번의 PocketBase 조회가 필요합니다. getFirstListItem을 56번 호출하니까 체감상 1~2초 걸리는데, 리뷰 페이지는 실시간 반응이 필요한 화면이 아니니까 감수했습니다. 라이브러리 없이 SVG로 직접 그리는 게 번들 크기 걱정도 없고, 커스터마이징도 자유로워서 이런 소규모 프로젝트에서는 오히려 나은 선택이었습니다.
4. Plan 레이어 보존
4-1. 문제: 계획 덮어쓰기 vs 2단계 조작
"4시부터 1시간 달리기 계획이었는데, 급한 회의가 들어와서 회의를 했다."
이 상황에서 기존에는 두 가지 선택지가 있었습니다.
- Plan을 회의로 덮어쓰기 → 원래 뭘 하려 했는지 사라짐
- Done만 회의로 표시 → Done을 먼저 누르고 나서 변경해야 해서 2단계 조작
둘 다 불편했습니다. 타임박싱의 핵심 가치가 "계획 vs 현실"의 비교인데, 계획 자체가 사라지면 비교할 게 없어집니다.
4-2. 해결: 길게 누르기 → 3가지 선택지
모바일 웹에서 "길게 누르기(long press)"를 구현했습니다. touchstart에서 타이머를 걸고, 일정 시간(여기서는 500ms) 안에 touchend가 오지 않으면 길게 누르기로 판정합니다.
Plan 셀을 길게 누르면 팝업이 뜹니다.
- ✅ 달리기 완료 (1시간) — 계획대로 Done 처리
- 🔄 다른 일 했음 — 기본 항목(출근/점심 등) + Brain Dump 항목에서 선택
- ✏️ 계획 수정 — 기존 Plan 편집 팝업
"다른 일 했음"을 선택하면 done-outlined로 마킹됩니다. Plan 파스텔 배경은 그대로 유지되고, 변경된 일의 색상이 테두리로 표시됩니다. 한눈에 "원래 뭘 하려 했고, 실제로 뭘 했는지"가 보입니다.

이 UX가 완성되면서 3-3의 계획 변경률 통계도 의미를 갖게 됐습니다. done-outlined가 있어야 "다른 일을 했다"를 구분할 수 있으니까요.
4-3. 블록 단위 vs 셀 단위 혼합
처음에는 "다른 일 했음"을 블록 전체에 적용했습니다. 그런데 "2시간 달리기 중 1시간만 하고 나머지 1시간은 회의"인 경우를 표현할 수 없었습니다.
최종 결론은 두 가지 제스처를 혼합하는 것이었습니다.
- 탭 → 셀 단위(15분) Done/해제. 세밀한 조절 가능.
- 길게 누르기 → 블록 전체 옵션. 빠른 일괄 처리.
예를 들어, "810시 달리기, 9시까지만 함"이면 89시만 탭탭 Done 처리하고, 9~10시는 Plan으로 남겨둡니다. 반대로 블록 전체를 한꺼번에 Done 처리하고 싶으면 길게 눌러서 "완료"를 선택하면 됩니다.
4-4. findBlock 함수
블록 범위를 자동 감지하는 함수가 핵심입니다. 같은 색상이 연속되어도 라벨이 다르면 별개 블록으로 인식해야 합니다.
function findBlock(cell) {
// 위로: 라벨 있는 셀(블록 시작) 찾기
// 아래로: 다음 라벨(다른 블록 시작) 전까지
}
동작 방식을 정리하면 이렇습니다.
- 현재 셀에서 위로 탐색 —
slot-label이 있는 셀을 만나면 거기가 블록 시작 - 블록 시작에서 아래로 탐색 — 같은 색상이 이어지되, 새로운
slot-label이 나오면 거기서 끊음
이 함수는 블록 삭제(clearBlock), 블록 Done, 블록 변경 모두에서 공유됩니다. 한 곳에서 블록 범위 로직을 관리하니 버그도 줄고 유지보수도 편합니다.

5. 폰트 통일 (Pretendard)
리뷰 페이지(review.html)가 메인 앱(index.html)과 폰트가 달랐습니다. 리뷰는 MaruBuri만 쓰고 있었고, 메인은 Pretendard를 쓰고 있었습니다. 한 앱에서 페이지마다 폰트가 다르면 사용자 입장에서 이질감이 큽니다.
@font-face에 Pretendard 3종(Regular, SemiBold, Bold)을 추가하고, font-family를 'Pretendard','A2G',-apple-system,sans-serif로 통일했습니다. 작은 변경이지만 리뷰 페이지로 넘어갈 때 "다른 앱에 온 것 같은" 느낌이 사라져서 만족스럽습니다.
6. 이 편에서 배운 것
- 통계는 라벨 기반이어야 합니다. 색상 인덱스로 집계하면 같은 색에 다른 일정이 배정되는 순간 수치가 엉망이 됩니다.
slot-label텍스트를 추적하는 게 정확합니다. - Plan 보존은 타임박싱의 핵심 가치입니다. "계획 vs 현실"의 차이를 볼 수 있어야 리뷰가 의미를 가집니다.
done-outlined로 시각적 구분까지 하면 한눈에 비교됩니다. - 셀 단위와 블록 단위를 혼합하면 유연성과 편의성을 모두 잡을 수 있습니다. 탭은 15분 단위 세밀 조작, 길게 누르기는 블록 일괄 처리. 두 제스처가 자연스럽게 공존합니다.
- SVG로 차트를 직접 그리는 건 생각보다 간단합니다.
<line>,<path>,<circle>,<text>네 가지 요소면 꺾은선 그래프가 완성됩니다. 라이브러리 없이도 충분합니다.
다음 편: #12 — 데이터 보호: 덮어쓰기 차단, 백업, 로그
메타 설명: 타임박싱 웹앱 리뷰 대시보드에 TOP5 랭킹, 계획 변경률, 달성률 추이 그래프를 추가하고, 길게 누르기로 Plan 레이어를 보존하는 UX를 구현한 과정을 정리합니다.
'IT > AI' 카테고리의 다른 글
| [AI] Ollama 로컬 AI 설치 및 실행 완벽 가이드 (0) | 2026.04.10 |
|---|---|
| [AI] Claude 3.5 Sonnet API 실전 활용 가이드: 최신 버전 기능과 가격 비교 (0) | 2026.04.10 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #12 — 데이터 보호: 덮어쓰기 차단, 백업, 로그 (0) | 2026.03.31 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #10 — 가족 대시보드, 주간 목표, 알림 (0) | 2026.03.29 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #9 — 소셜 시스템 + 인라인 캘린더 (0) | 2026.03.27 |
| [AI] Claude와 함께 타임박싱 웹앱 만들기 #8 — 인프라 보안 & 백업, 그리고 회고 (0) | 2026.03.26 |