fix(review): 전체 코드 리뷰 2차 완료 — 횟수 단위 en 단복수 + 명세 문서화

1차(c18e3c7)에 이어 나머지 전 영역을 세션 본체가 직접 정독 완료:
위젯 10파일·인텐트, 앱 셸(ContentView/Settings/Help/Radial/SymbolCatalog),
워치 전체(페이로드·동기화·앱·컴플리케이션), 일기 전체(6파일 ~4.2k줄),
내보내기 뷰부·CSV, MainView·ActionViews 잔여 — 추가 결함 1건만 발견.

[L] 모음 탭 횟수 셀: 큰 숫자와 단위("회")를 분리 렌더링하는 유일한
    지점이라 en에서 1일 때 "1 times"가 됨 — Format.countUnit(단복수
    분기, 명시 키 count.unit.one/other)로 수정. 단독 "회" 키는 사용처
    소멸로 정리 (ja 「回」는 단복수 무관)

문서(CLAUDE.md): 이번 리뷰로 확정된 동작 규칙 4건을 명세에 반영 —
세션 편집 원본 초 보존·0길이 차단(§6.5), 타임테이블 HH:mm 라벨(§6.5),
필터 활성 실재 행동 기준(§6.5), 통계 동명 구분 Format.disambiguated(§6.6)

정독 제외(사유 명시): DebugSeed·WidgetPreviewScreen·ComplicationPreview
(DEBUG 전용), 워치 엔트리 2파일(41줄), HelpView 본문 문구부(카탈로그
검증으로 커버), ExportImageView PhotoSaver(~40줄)

검증: Debug/Store 빌드, 카탈로그 missing/stale 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
songyc macbook 2026-07-22 21:17:38 +09:00
parent c18e3c7c6f
commit 14b18bc22a
4 changed files with 61 additions and 21 deletions

View File

@ -204,14 +204,14 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
### 6.5 기록 탭 (`HistoryView`, `RecordEditors`)
- **범위 조회**: 기록은 전량 로드하지 않고 선택 날짜가 속한 **주 범위**만 @Query로 가져온다(`HistoryRecordsView` — SwiftData 동적 쿼리 패턴: 날짜가 바뀌면 새 조건으로 재생성). 하루 경계 걸침·진행 중 세션은 "시작 < 범위끝 AND (종료 없음 OR 종료 > 범위시작)" 겹침 조건으로 포함
- 날짜 헤더(화살표 이동, 날짜 탭=달력 팝오버, "오늘" 버튼) + 목록/타임테이블 세그먼트
- 목록: 시간 기록(시작~종료·총 시간·메모)·횟수 기록(시각·+n·메모), 탭하면 수정 시트. **수정·추가 시트의 시각 입력은 상시 노출 휠 다이얼**(`RecordEditors.swift` RecordTimeWheel) — 컴팩트 팝오버는 다이얼 감속 중 닫히면 정착값이 유실되는 실기기 보고가 있어 폐기. `in:` 범위 제약도 하한 근처 선택을 소리 없이 되감아 금지(검증은 종료<시작 경고 문구+저장 비활성). 편집 상태는 단위 절사(flooredToMinute) 실측정 기록의 찌꺼기(14:32:47) 남으면 다이얼로 맞춘 1시간이 59분으로 표시되는 어긋남 방지. **앱의 모든 시각 입력은 `CollapsibleTimeWheel`**(RecordEditors.swift) 평소엔 (라벨+현재 +셰브론), 탭하면 인라인 (RecordTimeWheel) 펼침. 상시 노출 휠이 어수선하다는 실기기 피드백으로 기록 편집 4시트·다짐 '마감 시각설정 '하루 시작 시간' 전부 통일(기본 접힘). **펼침/접힘은 조건부 삽입(if)이 아니라 높이 0↔216 프레임 보간**(.smooth 0.3s) 삽입 방식은 List 높이가 스냅되어 '' 열리는 느낌(실기기 피드백). 휠은 Color.clear의 overlay로 띄워 UIDatePicker 고유 높이가 레이아웃에 개입하지 않게 한다(접힘 상태 공간 버그 방지 구조를 유지할 ). 팝오버 금지 이유(감속 닫힘 정착값 유실) 범위 제약 금지· 절사 규칙은 그대로
- 타임테이블: 24시간 축(하루 시작 시간 기준), 하루/일주일 전환(주간 보기에서 화살표는 7일씩 점프). 시간=색 블록, 횟수=색 점. 블록/점 탭=수정
- 필터: `RecordFilterSheet`(공용) — 꼬리표 아래 행동이 그룹된 트리, **"제외한 행동 ID" 집합**으로 관리해 기본이 전체 선택. 활성 시 초록 칩 표시
- 목록: 시간 기록(시작~종료·총 시간·메모)·횟수 기록(시각·+n·메모), 탭하면 수정 시트. **수정·추가 시트의 시각 입력은 상시 노출 휠 다이얼**(`RecordEditors.swift` RecordTimeWheel) — 컴팩트 팝오버는 다이얼 감속 중 닫히면 정착값이 유실되는 실기기 보고가 있어 폐기. `in:` 범위 제약도 하한 근처 선택을 소리 없이 되감아 금지(검증은 종료<시작 경고 문구+저장 비활성). 편집 상태는 단위 절사(flooredToMinute) 실측정 기록의 찌꺼기(14:32:47) 남으면 다이얼로 맞춘 1시간이 59분으로 표시되는 어긋남 방지. **저장은 다이얼을 움직인 필드만 절사 값으로 쓰고, 안 움직인 필드는 원본 시각(초 포함)을 보존**(effectiveStart/End 메모만 고쳐도 1분 미만 세션이 0초로 파괴되던 결함의 수정, 2026-07-22 전체 리뷰). 시작=종료(0길이) 저장은 경고 문구+비활성으로 차단(목록·타임테이블·내보내기 어디에도 보이는 유령 기록 방지). **앱의 모든 시각 입력은 `CollapsibleTimeWheel`**(RecordEditors.swift) 평소엔 (라벨+현재 +셰브론), 탭하면 인라인 (RecordTimeWheel) 펼침. 상시 노출 휠이 어수선하다는 실기기 피드백으로 기록 편집 4시트·다짐 '마감 시각설정 '하루 시작 시간' 전부 통일(기본 접힘). **펼침/접힘은 조건부 삽입(if)이 아니라 높이 0↔216 프레임 보간**(.smooth 0.3s) 삽입 방식은 List 높이가 스냅되어 '' 열리는 느낌(실기기 피드백). 휠은 Color.clear의 overlay로 띄워 UIDatePicker 고유 높이가 레이아웃에 개입하지 않게 한다(접힘 상태 공간 버그 방지 구조를 유지할 ). 팝오버 금지 이유(감속 닫힘 정착값 유실) 범위 제약 금지· 절사 규칙은 그대로
- 타임테이블: 24시간 축(하루 시작 시간 기준), 하루/일주일 전환(주간 보기에서 화살표는 7일씩 점프). 시간=색 블록, 횟수=색 점. 블록/점 탭=수정. **하루 시작이 정시가 아니면(예 06:30) 축 라벨을 HH:mm으로 표기**(행 경계가 06:30~07:30이라 시만 쓰면 최대 59분 어긋남 — 화면·내보내기 동일 규칙, 2026-07-22)
- 필터: `RecordFilterSheet`(공용) — 꼬리표 아래 행동이 그룹된 트리, **"제외한 행동 ID" 집합**으로 관리해 기본이 전체 선택. 활성 시 초록 칩 표시. 활성 판정(isFiltering)은 **실재 행동 기준** — 제외했던 행동을 삭제하면 잔존 ID를 무시해 칩·내보내기 필터 문구가 허위로 남지 않는다(기록·통계 공통, 2026-07-22)
- 내보내기: 현재 날짜/모드/필터 그대로 `ExportBuilder.history` 스냅숏 → 이미지 시트
### 6.6 통계 탭 (`StatsTabView`) — 기록과 분리된 독립 탭
- **범위 조회 + 1회 버킷 집계**(`StatsChartsView`): 보고 있는 기간 범위만 @Query로 가져오고, 조회된 기록을 한 번만 순회해 행동별·하루별 버킷(`StatsData`)을 만들어 모든 카드가 공유 — 카드마다 Aggregator로 전체 기록을 재순회하지 않음. 하루 분할은 `splitByDay`(기존과 동일 수치). 대량 검증은 `-seedBulk`
- 하루: 꼬리표별 시간/횟수 가로 막대. 주간: 행동별 일별 꺾은선(행동 색+범례)+합계 막대+합계·평균 표. 월간: 주차별+일별 꺾은선+합계+표
- 하루: 꼬리표별 시간/횟수 가로 막대. 주간: 행동별 일별 꺾은선(행동 색+범례)+합계 막대+합계·평균 표. 월간: 주차별+일별 꺾은선+합계+표. **동명 행동/꼬리표는 표시 이름을 "이름 (2)"로 구분**(`Format.disambiguated` — 이름이 곧 시리즈 키·범례·Identifiable id라 동명이 한 선으로 합쳐지고 id가 충돌하던 결함의 수정. 통계 탭·통계 내보내기·⑤ 위젯 3표면 공통, 꼬리표 집계는 identity 키. 2026-07-22)
- 평균 분모는 "이미 시작된" 날/주만 (미래로 희석 방지). 필터·내보내기는 기록 탭과 동일 패턴
### 6.7 일기 탭 (iPad 전용, 프리미엄 — `DiaryView`, `DiaryNotePage`, `DiaryCalendarEvents`, `DiaryExportSheet`)

View File

@ -1232,6 +1232,52 @@
}
}
},
"count.unit.one" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "time"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "回"
}
},
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : "회"
}
}
}
},
"count.unit.other" : {
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "times"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "回"
}
},
"ko" : {
"stringUnit" : {
"state" : "new",
"value" : "회"
}
}
}
},
"iCloud 동기화는 네트워크 상황에 따라 몇 초에서 몇 분까지 걸릴 수 있어요. 다른 기기의 기록이 안 보이면 모음 탭 왼쪽 위 새로고침 버튼을 눌러 주세요. 그래도 안 보이면 잠시 후 다시 시도하거나 두 기기 모두 인터넷 연결을 확인해 주세요." : {
"localizations" : {
"en" : {
@ -12828,22 +12874,6 @@
}
}
},
"회" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "times"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "回"
}
}
}
},
"횟수" : {
"localizations" : {
"en" : {

View File

@ -1286,7 +1286,7 @@ struct ActionButtonCell: View {
.monospacedDigit()
.foregroundStyle(.white)
.contentTransition(.numericText(value: Double(todayCount)))
Text("")
Text(Format.countUnit(todayCount))
.font((compact ? Font.caption2 : Font.caption).weight(.semibold))
.foregroundStyle(.white.opacity(0.85))
}

View File

@ -68,6 +68,16 @@ enum Format {
comment: "횟수 평균 (소수 1자리)")
}
/// ("") ,
/// . "" (en "times") 1 "1 times"
/// (time/times) . : ko ""
///
static func countUnit(_ count: Int) -> String {
count == 1
? String(localized: "count.unit.one", defaultValue: "")
: String(localized: "count.unit.other", defaultValue: "")
}
/// ··Identifiable id
/// ( · · ) /
/// id . ,