Daeseon Yoo
Back to project
·Update·3 min

U4 팩별 진행과 조용한 마일스톤

Practice 팩 카드에 U1 mastery 진행을 연결하고, 사용자별 로컬 receipt로 마스터·스트릭·첫 스파링 축하를 한 번만 큐잉한다.

문제

Practice 화면은 팩마다 전체 카드 수와 오늘 due만 보여 줬다. 이미 익힌 수가 보이지 않아 같은 팩으로 돌아왔을 때 누적 진전을 확인할 수 없었다. 마스터 100개, 스트릭 7·30·100일, 첫 스파링 완료도 별도 보상 경제 없이 짧게 축하할 공통 경계가 없었다.

팩별 진행

U1의 useMastery(srs).byPack을 8개 정적 팩 카드에 직접 연결했다. 각 카드는 {mastered}/{total} 메타와 4px 진행바를 함께 표시한다. due 계산은 기존 card key prefix를 그대로 유지하고 mastery만 PracticePackId로 읽기 때문에 두 수치의 의미가 섞이지 않는다.

한 번만 보이는 마일스톤

사용자별 로컬 상태는 마지막 mastered·streak, 첫 스파링 완료 flag, celebrated 목록, pending queue를 저장한다. 동일 JS runtime의 storage 작업과 presentation lease를 직렬화해 StrictMode식 중복 claim을 막고, mastery와 streak가 동시에 임계를 넘으면 두 receipt를 순서대로 보존한다. 스트릭이 한 번에 여러 임계를 건너면 가장 높은 임계만 보여 준다.

토스트는 focus된 Practice에서만 claim한다. 실제 레이아웃이 잡힌 뒤 receipt를 확정하므로 그 전 화면 이탈은 pending을 보존한다. scale/fade는 160ms/180ms이고 2.2초 동안 머문다. reduced-motion 설정에서는 timing animation을 건너뛴다. iOS 토스트는 accessibility element로 선언하고 레이아웃 시 announcement를 보낸다. 뱃지 그리드, XP, 코인, 리더보드, confetti는 추가하지 않았다.

검증 범위

최종 커밋에서 TypeScript와 cached diff check는 stdout 없이 exit 0이었다. 99→100 저장값 조작, 동시 claim, 독립 queue drain, 첫 스파링 receipt 재호출 시뮬레이션 출력은 다음과 같았다.

{"manipulated":"99-to-100","concurrentClaims":1,"queued":["mastery-100","streak-7"],"repeat":null,"firstSparring":"one-time"}

iOS Simulator에서 다크 토스트, 라이트·다크 팩 카드, 128/502 mastered, 재진입 시 동일 토스트 미노출을 확인했다. 최종 head의 2.2초 설정에서는 claim·render·layout·acknowledge 뒤 다음 claim이 null인 순서를 로그로 확인했다. Maestro settle이 2.2초보다 길어 blur 자동화에서만 표시 시간을 임시 20초로 늘렸고, 토스트 visible → Today → Practice → not visible 및 128/502 mastered가 모두 COMPLETED였다. 임시 시간과 진단 로그를 제거한 뒤 TypeScript와 clean worktree를 다시 확인했다.

실제 스파링 완료 caller는 U3 금지 파일 범위라 [unverified]이며, 웹 다중 탭 원자성, 실기기 VoiceOver/TalkBack, reduced-motion 런타임도 [unverified]다.

구현 커밋: dd65821ddc45af58c8b5a88b71300dbc7c74839e.