fix(history): record time editing — always-visible wheel dials

- 기록 수정·추가 시트 4곳의 시각 입력을 컴팩트 팝오버에서 상시 노출 휠 다이얼(RecordTimeWheel)로 교체 — 팝오버 다이얼이 감속 중 닫히면 정착값이 바인딩에 반영되지 않던 실기기 보고 수정. 다이얼 = 표시 그 자체라 저장 전 재확인 불필요
- 종료 시각의 in: startAt... 범위 제약 제거 — 초 단위 하한 근처 선택을 소리 없이 되감던 스냅백 원인. 검증은 종료<시작 빨간 경고 + 저장/추가 버튼 비활성으로 대체 (숨은 보정 제거)
- 편집 상태 분 단위 절사(flooredToMinute) — 실측정 기록의 초 찌꺼기(14:32:47)로 다이얼상 1시간이 "59분"으로 표시되던 어긋남 수정. 저장은 화면 값 그대로
- 검증 인자 신설: -recordShowEditor session|count(기록 탭 최신 기록 수정 시트), -recordAddFor "이름"(모음 탭 기록 직접 입력 시트)
- l10n: 경고 문구 1키 ko/en/ja, 카탈로그 missing/stale 클린. CLAUDE.md §6.5·§14 갱신
- 검증: Debug/Store 두 스킴 빌드, 수정·추가·횟수 시트 시뮬 스크린샷, -progressSelfTest 37건 ALL PASS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014CkLDi8Lr21vGqo4SjZaTp
This commit is contained in:
songyc macbook 2026-07-17 23:08:09 +09:00
parent a881646b49
commit 9825be505f
5 changed files with 94 additions and 16 deletions

View File

@ -199,7 +199,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
### 6.5 기록 탭 (`HistoryView`, `RecordEditors`) ### 6.5 기록 탭 (`HistoryView`, `RecordEditors`)
- **범위 조회**: 기록은 전량 로드하지 않고 선택 날짜가 속한 **주 범위**만 @Query로 가져온다(`HistoryRecordsView` — SwiftData 동적 쿼리 패턴: 날짜가 바뀌면 새 조건으로 재생성). 하루 경계 걸침·진행 중 세션은 "시작 < 범위끝 AND (종료 없음 OR 종료 > 범위시작)" 겹침 조건으로 포함 - **범위 조회**: 기록은 전량 로드하지 않고 선택 날짜가 속한 **주 범위**만 @Query로 가져온다(`HistoryRecordsView` — SwiftData 동적 쿼리 패턴: 날짜가 바뀌면 새 조건으로 재생성). 하루 경계 걸침·진행 중 세션은 "시작 < 범위끝 AND (종료 없음 OR 종료 > 범위시작)" 겹침 조건으로 포함
- 날짜 헤더(화살표 이동, 날짜 탭=달력 팝오버, "오늘" 버튼) + 목록/타임테이블 세그먼트 - 날짜 헤더(화살표 이동, 날짜 탭=달력 팝오버, "오늘" 버튼) + 목록/타임테이블 세그먼트
- 목록: 시간 기록(시작~종료·총 시간·메모)·횟수 기록(시각·+n·메모), 탭하면 수정 시트 - 목록: 시간 기록(시작~종료·총 시간·메모)·횟수 기록(시각·+n·메모), 탭하면 수정 시트. **수정·추가 시트의 시각 입력은 상시 노출 휠 다이얼**(`RecordEditors.swift` RecordTimeWheel) — 컴팩트 팝오버는 다이얼 감속 중 닫히면 정착값이 유실되는 실기기 보고가 있어 폐기. `in:` 범위 제약도 하한 근처 선택을 소리 없이 되감아 금지(검증은 종료<시작 경고 문구+저장 비활성). 편집 상태는 단위 절사(flooredToMinute) 실측정 기록의 찌꺼기(14:32:47) 남으면 다이얼로 맞춘 1시간이 59분으로 표시되는 어긋남 방지. 마감 시각(다짐하루 시작 시간(설정) hourAndMinute 컴팩트 픽커는 편집 빈도가 낮아 유지 같은 증상 보고 RecordTimeWheel 패턴 적용
- 타임테이블: 24시간 축(하루 시작 시간 기준), 하루/일주일 전환(주간 보기에서 화살표는 7일씩 점프). 시간=색 블록, 횟수=색 점. 블록/점 탭=수정 - 타임테이블: 24시간 축(하루 시작 시간 기준), 하루/일주일 전환(주간 보기에서 화살표는 7일씩 점프). 시간=색 블록, 횟수=색 점. 블록/점 탭=수정
- 필터: `RecordFilterSheet`(공용) — 꼬리표 아래 행동이 그룹된 트리, **"제외한 행동 ID" 집합**으로 관리해 기본이 전체 선택. 활성 시 초록 칩 표시 - 필터: `RecordFilterSheet`(공용) — 꼬리표 아래 행동이 그룹된 트리, **"제외한 행동 ID" 집합**으로 관리해 기본이 전체 선택. 활성 시 초록 칩 표시
- 내보내기: 현재 날짜/모드/필터 그대로 `ExportBuilder.history` 스냅숏 → 이미지 시트 - 내보내기: 현재 날짜/모드/필터 그대로 `ExportBuilder.history` 스냅숏 → 이미지 시트
@ -328,10 +328,10 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
| 영역 | 인자 | | 영역 | 인자 |
|---|---| |---|---|
| 모음 | `-startEditing` `-expandGoalCard` `-pinGoals <N>` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` | | 모음 | `-startEditing` `-expandGoalCard` `-pinGoals <N>` `-openStatsFor "이름"` `-openHistoryFor "이름"` `-autoStart "이름"` `-recordAddFor "이름"`(기록 직접 입력 시트) |
| 측정 알림 | `-settings.longSessionAlertHours <N>`(설정 주입) `-longSessionAlertTestSeconds <N>`(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) | | 측정 알림 | `-settings.longSessionAlertHours <N>`(설정 주입) `-longSessionAlertTestSeconds <N>`(시간 대신 N초 발화, provisional 권한 자동) `-alertDump`(예약 상태 → Documents/session-alerts.txt) |
| 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals <N>`(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 37건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) | | 목표 | `-goalShowEditor` `-goalShowFinished` `-goalReorder` `-goalScrollBottom` `-questShowEditor`(다짐 편집기 표시 — 마감 시각 다짐 우선, 없으면 첫 다짐) `-endGoalYesterday "제목"`(종료일을 어제로 — 실행 시점 자동 판정 검증) `-watchGoals <N>`(생성순 목표 N개를 '애플워치에서 보기'로 설정, 0=전체 해제 — 설정 후 스냅숏 즉시 푸시, 컴플리케이션 필터 검증용) `-streakDump`(모든 다짐의 연속 계산 결과 → Documents/streak-dump.txt) `-progressSelfTest`(spanProgress 수치 37건 자가 검증[환산 캡·수행일 집계·마감·atMost·custom] → Documents/progress-self-test.txt — 인메모리 컨테이너·설정 고정이라 실데이터/기기 설정 무영향) |
| 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) | | 기록/통계 | `-historyMode timetable` `-historyWeekly` `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan <day|week|month>` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor <session|count>`(최신 기록 수정 시트) |
| 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) | | 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-premiumPreview` `-helpPreview` `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-widgetPreview YES|lock` `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) |
| 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) | | 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) |
| 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage <N>` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` | | 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage <N>` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` |

View File

@ -9197,6 +9197,22 @@
} }
} }
}, },
"종료 시각이 시작 시각보다 빨라요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "The end time is earlier than the start time."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "終了時刻が開始時刻より前になっています。"
}
}
}
},
"종료됨": { "종료됨": {
"localizations": { "localizations": {
"en": { "en": {

View File

@ -168,6 +168,15 @@ struct HistoryView: View {
if UserDefaults.standard.bool(forKey: "showExport") { if UserDefaults.standard.bool(forKey: "showExport") {
showingExport = true showingExport = true
} }
// : -recordShowEditor session|count
switch UserDefaults.standard.string(forKey: "recordShowEditor") {
case "session":
editingSession = allActions.flatMap(\.sessions).sorted { $0.startAt > $1.startAt }.first
case "count":
editingEntry = allActions.flatMap(\.countEntries).sorted { $0.timestamp > $1.timestamp }.first
default:
break
}
#endif #endif
} }
.onChange(of: router.pendingHistoryActionID) { .onChange(of: router.pendingHistoryActionID) {

View File

@ -139,6 +139,13 @@ struct MainView: View {
router.openHistory(filtering: action.persistentModelID, visibleTabsRaw: visibleTabsRaw) router.openHistory(filtering: action.persistentModelID, visibleTabsRaw: visibleTabsRaw)
} }
} }
// : -recordAddFor "" ' ·'
if let name = UserDefaults.standard.string(forKey: "recordAddFor"),
let action = actions.first(where: { $0.name == name }) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
recordsAction = action
}
}
} }
#endif #endif
} }

View File

@ -8,6 +8,32 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
/// ()
/// ( 14:32:47) 15:32
/// 59 13 "1" "59" .
private func flooredToMinute(_ date: Date) -> Date {
let comps = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: date)
return Calendar.current.date(from: comps) ?? date
}
/// .
///
/// ( ),
/// . (in:)
/// , · .
private struct RecordTimeWheel: View {
let label: String
@Binding var selection: Date
var body: some View {
DatePicker(label, selection: $selection)
.datePickerStyle(.wheel)
.labelsHidden()
.frame(maxWidth: .infinity)
.accessibilityLabel(label)
}
}
// MARK: - ( ) // MARK: - ( )
struct ActionRecordsSheet: View { struct ActionRecordsSheet: View {
@ -190,12 +216,17 @@ struct SessionEditorView: View {
NavigationStack { NavigationStack {
Form { Form {
Section("시작") { Section("시작") {
DatePicker("시작 시각", selection: $startAt) RecordTimeWheel(label: String(localized: "시작 시각"), selection: $startAt)
} }
Section("종료") { Section("종료") {
Toggle("종료됨", isOn: $isFinished.animation()) Toggle("종료됨", isOn: $isFinished.animation())
if isFinished { if isFinished {
DatePicker("종료 시각", selection: $endAt, in: startAt...) RecordTimeWheel(label: String(localized: "종료 시각"), selection: $endAt)
if endAt < startAt {
Text("종료 시각이 시작 시각보다 빨라요.")
.font(.caption)
.foregroundStyle(.red)
}
} else { } else {
Text("종료 시각을 끄면 진행 중 상태가 돼요.") Text("종료 시각을 끄면 진행 중 상태가 돼요.")
.font(.caption) .font(.caption)
@ -222,23 +253,26 @@ struct SessionEditorView: View {
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("저장") { Button("저장") {
// ( )
// (14:32~15:32) (59 13)
session.startAt = startAt session.startAt = startAt
session.endAt = isFinished ? max(endAt, startAt) : nil session.endAt = isFinished ? max(endAt, startAt) : nil
session.note = note.trimmingCharacters(in: .whitespacesAndNewlines) session.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
DataChange.commit(context: context) DataChange.commit(context: context)
dismiss() dismiss()
} }
.disabled(isFinished && endAt < startAt)
} }
} }
.onAppear { .onAppear {
startAt = session.startAt startAt = flooredToMinute(session.startAt)
note = session.note note = session.note
if let end = session.endAt { if let end = session.endAt {
isFinished = true isFinished = true
endAt = end endAt = flooredToMinute(end)
} else { } else {
isFinished = false isFinished = false
endAt = .now endAt = flooredToMinute(.now)
} }
} }
} }
@ -252,15 +286,24 @@ struct SessionAddView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
let action: Action let action: Action
@State private var startAt: Date = .now.addingTimeInterval(-3600) @State private var startAt: Date = flooredToMinute(.now.addingTimeInterval(-3600))
@State private var endAt: Date = .now @State private var endAt: Date = flooredToMinute(.now)
@State private var note = "" @State private var note = ""
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
DatePicker("시작 시각", selection: $startAt) Section("시작") {
DatePicker("종료 시각", selection: $endAt, in: startAt...) RecordTimeWheel(label: String(localized: "시작 시각"), selection: $startAt)
}
Section("종료") {
RecordTimeWheel(label: String(localized: "종료 시각"), selection: $endAt)
if endAt < startAt {
Text("종료 시각이 시작 시각보다 빨라요.")
.font(.caption)
.foregroundStyle(.red)
}
}
Section("메모") { Section("메모") {
TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical) TextField("이 기록에 대한 메모 (선택)", text: $note, axis: .vertical)
.lineLimit(2...5) .lineLimit(2...5)
@ -280,6 +323,7 @@ struct SessionAddView: View {
DataChange.commit(context: context) DataChange.commit(context: context)
dismiss() dismiss()
} }
.disabled(endAt < startAt)
} }
} }
} }
@ -300,7 +344,9 @@ struct CountEntryEditorView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
DatePicker("기록 시각", selection: $timestamp) Section("기록 시각") {
RecordTimeWheel(label: String(localized: "기록 시각"), selection: $timestamp)
}
Stepper(value: $amount, in: 1...9999) { Stepper(value: $amount, in: 1...9999) {
HStack { HStack {
Text("수량") Text("수량")
@ -337,7 +383,7 @@ struct CountEntryEditorView: View {
} }
} }
.onAppear { .onAppear {
timestamp = entry.timestamp timestamp = flooredToMinute(entry.timestamp)
amount = entry.amount amount = entry.amount
note = entry.note note = entry.note
} }
@ -354,7 +400,7 @@ struct CountAddView: View {
@State private var amount = 1 @State private var amount = 1
@State private var useNow = true @State private var useNow = true
@State private var timestamp: Date = .now @State private var timestamp: Date = flooredToMinute(.now)
@State private var note = "" @State private var note = ""
var body: some View { var body: some View {
@ -374,7 +420,7 @@ struct CountAddView: View {
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
if !useNow { if !useNow {
DatePicker("시각", selection: $timestamp) RecordTimeWheel(label: String(localized: "기록 시각"), selection: $timestamp)
} }
} }
Section("메모") { Section("메모") {