feat(diary): per-date action filter for the summary timetable

일기 타임테이블에서 그날 기록된 행동 중 원하는 것만 골라 볼 수 있게.
캘린더 일정 선택과 마찬가지로 날짜별로 저장되고 내보내기에도 반영된다.

- DiaryEntry.hiddenActionIDs (행동 uuid 문자열, 날짜별·동기화 안전)
- 타임테이블 카드 우상단에 필터 버튼 추가 (숨긴 개수 배지, 캘린더 버튼 옆)
- 기존 RecordFilterSheet 재사용 — 그날 기록된 행동만 꼬리표별로 나열
- 필터는 타임테이블에만 적용, 기록 목록·요약 통계는 전체 유지
  (ExportSnapshot.replacingTimetableSections로 내보내기도 동일 규칙)
- 도움말 갱신: 목표 달성 판정 기준, 일기 타임테이블 일정·필터 항목
- en/ja 번역 추가 (Live Activity의 미번역 문자열 3건도 함께 보충)
- 검증용 런치 인자 -diaryShowActionFilter, -diaryHideFirstAction 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-13 18:24:22 +09:00
parent 85a98c1ab0
commit 93dd3d1d9f
6 changed files with 341 additions and 21 deletions

View File

@ -827,6 +827,22 @@
}
}
},
"+%lld개 함께 추적 중": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "+%lld more tracking"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "+%lld件も計測中"
}
}
}
},
"1.0": {
"localizations": {
"en": {
@ -2373,6 +2389,22 @@
}
}
},
"다른 행동 %lld개도 함께 추적 중": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Also tracking %lld other actions"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "他の行動%lld件も同時に計測中"
}
}
}
},
"다시 만들기": {
"localizations": {
"en": {
@ -2613,6 +2645,22 @@
}
}
},
"다짐 평균 달성률": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Average quest completion"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "クエストの平均達成率"
}
}
}
},
"다짐 현황": {
"localizations": {
"en": {
@ -2901,6 +2949,22 @@
}
}
},
"달성 판정 기준": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Achievement Threshold"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "達成判定の基準"
}
}
}
},
"달성률 기간": {
"localizations": {
"en": {
@ -3933,6 +3997,22 @@
}
}
},
"목표가 끝날 때 다짐들의 평균 달성률이 이 값 이상이면 '달성 완료'로 판정해요. 100%면 모든 다짐을 채워야 달성이에요. 다짐이 없는 목표는 지금처럼 직접 선택해요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "When the goal ends, it counts as 'Achieved' if the quests' average completion rate is at or above this value. At 100%, every quest must be fulfilled. Goals without quests are still decided by you directly."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "目標の終了時、クエストの平均達成率がこの値以上なら「達成済み」と判定されます。100%の場合はすべてのクエストを満たす必要があります。クエストのない目標はこれまで通り自分で選択します。"
}
}
}
},
"목표는 '토익 700점 받기'처럼 큰 방향이고, 다짐은 그 아래 실천 단위예요. 다짐은 특정 행동(또는 꼬리표)을 골라 주기(하루·주간·월간·특정 기간)와 목표량을 정해요. '이상 달성'뿐 아니라 '이하 유지'(예: 영상 시청 하루 1시간 이하)도 만들 수 있어요.": {
"localizations": {
"en": {
@ -7193,6 +7273,22 @@
}
}
},
"종료 시 다짐 평균 달성률이 %lld%% 이상이면 달성으로 판정돼요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "When finished, the goal counts as achieved if the quests' average completion rate is %lld%% or higher."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "終了時、クエストの平均達成率が%lld%%以上なら達成と判定されます。"
}
}
}
},
"종료 시 하위 다짐들의 달성 여부에 따라 달성/미달성이 결정돼요.": {
"localizations": {
"en": {
@ -7290,6 +7386,7 @@
}
},
"종료일이 있는 목표는 날짜가 지나면 자동으로 달성 여부가 판정되고, 종료일이 없는 목표는 수동 종료 버튼으로 마칠 수 있어요. 다짐 없이 종료일이 지난 목표는 '달성했나요?' 상태가 되어 직접 선택해요. 완료된 목표는 목록 하단의 완료 보관함에서 볼 수 있어요.": {
"extractionState": "stale",
"localizations": {
"en": {
"stringUnit": {
@ -7305,6 +7402,22 @@
}
}
},
"종료일이 있는 목표는 날짜가 지나면 자동으로 달성 여부가 판정되고, 종료일이 없는 목표는 수동 종료 버튼으로 마칠 수 있어요. 판정은 다짐들의 평균 달성률이 목표를 만들 때 정한 '달성 판정 기준'(기본 100%) 이상이면 달성이에요 — 예를 들어 기준을 80%로 두면 조금 못 채워도 달성으로 기록돼요. 다짐 없이 종료일이 지난 목표는 '달성했나요?' 상태가 되어 직접 선택해요. 완료된 목표는 목록 하단의 완료 보관함에서 볼 수 있어요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Goals with an end date are judged automatically once the date passes, and goals without one can be finished with the manual finish button. A goal counts as achieved when the quests' average completion rate meets the 'Achievement Threshold' you set when creating it (default 100%) — for example, with an 80% threshold it still counts as achieved even if slightly short. A goal whose end date passes with no quests switches to a 'Did you achieve it?' state for you to decide. Finished goals live in the archive at the bottom of the list."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "終了日のある目標は日付が過ぎると自動で達成が判定され、終了日のない目標は手動終了ボタンで終えられます。判定は、目標作成時に決めた「達成判定の基準」(初期値100%)以上にクエストの平均達成率が達していれば達成です — 例えば基準を80%にすると、少し届かなくても達成として記録されます。クエストがないまま終了日が過ぎた目標は「達成しましたか?」の状態になり、自分で選択します。完了した目標はリスト下部の完了アーカイブで確認できます。"
}
}
}
},
"주 시작 요일": {
"localizations": {
"en": {
@ -7753,6 +7866,22 @@
}
}
},
"첫 페이지 타임테이블 카드 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 표시할 수 있어요. 필터 버튼을 누르면 그날 기록된 행동 중 타임테이블에 보여줄 것만 고를 수 있어요. 두 선택 모두 날짜마다 따로 저장되고, 일기를 내보낼 때도 그대로 반영돼요.": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Use the calendar button at the top right of the first page's timetable card to pick that day's calendar events to show alongside your records. The filter button lets you choose which of that day's recorded actions appear on the timetable. Both choices are saved per date and carry over to diary exports."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "最初のページのタイムテーブルカード右上のカレンダーボタンで、その日のカレンダー予定を選んで一緒に表示できます。フィルターボタンを押すと、その日に記録した行動のうちタイムテーブルに表示するものだけを選べます。どちらの選択も日付ごとに保存され、日記の書き出しにもそのまま反映されます。"
}
}
}
},
"첫 화면 구성": {
"localizations": {
"en": {
@ -7961,6 +8090,22 @@
}
}
},
"측정 중": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Tracking"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "計測中"
}
}
}
},
"측정 중인 행동의 시간 측정을 종료해요.": {
"localizations": {
"en": {
@ -8105,6 +8250,38 @@
}
}
},
"타임테이블 필터": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Timetable Filter"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "タイムテーブルフィルター"
}
}
}
},
"타임테이블에 일정·필터": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Timetable Events & Filter"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "タイムテーブルの予定・フィルター"
}
}
}
},
"탭 메뉴 열기": {
"localizations": {
"en": {
@ -9979,4 +10156,4 @@
}
},
"version": "1.0"
}
}

View File

@ -226,14 +226,29 @@ struct DiaryExportSheet: View {
@MainActor
private func renderPages(_ entry: DiaryEntry) -> [UIImage] {
var images: [UIImage] = []
let snapshot = ExportBuilder.history(
var snapshot = ExportBuilder.history(
dayKey: entry.dayKey, weeklyTimetable: false,
sessions: sessions, entries: countEntries,
orderedActions: orderedActions, excludedActionIDs: [], math: math,
trimHours: false
)
// ( ) ( )
let hidden = Set(entry.hiddenActionIDs)
if !hidden.isEmpty {
let excluded = Set(
orderedActions.filter { hidden.contains($0.uuid.uuidString) }.map(\.persistentModelID)
)
if !excluded.isEmpty {
snapshot = snapshot.replacingTimetableSections(with: ExportBuilder.history(
dayKey: entry.dayKey, weeklyTimetable: false,
sessions: sessions, entries: countEntries,
orderedActions: orderedActions, excludedActionIDs: excluded, math: math,
trimHours: false
))
}
}
// ( ) 24 +
.injectingCalendarEvents(DiaryCalendarEvents.timetableBlocks(for: entry, math: math))
snapshot = snapshot.injectingCalendarEvents(DiaryCalendarEvents.timetableBlocks(for: entry, math: math))
let summary = DiaryPrintSummaryView(entry: entry, snapshot: snapshot)
let summaryRenderer = ImageRenderer(content: summary)
summaryRenderer.scale = 2

View File

@ -607,6 +607,7 @@ private struct DiarySummaryPage: View {
@Query private var countEntries: [CountEntry]
@Query(sort: \Action.createdAt) private var allActionsQuery: [Action]
@Query(sort: \Goal.sortOrder) private var allGoals: [Goal]
@Query(sort: \Tag.sortOrder) private var allTags: [Tag]
@AppStorage(LocalPrefsKeys.actionOrder, store: AppGroup.defaults) private var actionOrderRaw = ""
@AppStorage(DiarySectionConfig.orderKey) private var sectionOrderRaw = ""
@AppStorage(DiarySectionConfig.hiddenKey) private var hiddenSectionsRaw = ""
@ -621,6 +622,11 @@ private struct DiarySummaryPage: View {
return false
}()
/// ,
/// entry.hiddenActionIDs(uuid , ) .
@State private var showingActionFilter = false
@State private var filterExcluded: Set<PersistentIdentifier> = []
private var math: DayMath { DayMath() }
/// ( , )
@ -666,6 +672,44 @@ private struct DiarySummaryPage: View {
)
}
/// ( uuid ID)
private var timetableExcludedIDs: Set<PersistentIdentifier> {
guard !entry.hiddenActionIDs.isEmpty else { return [] }
let hidden = Set(entry.hiddenActionIDs)
return Set(orderedActions.filter { hidden.contains($0.uuid.uuidString) }.map(\.persistentModelID))
}
/// ( ) . · (daySnapshot) .
private var timetableSnapshot: ExportSnapshot {
guard !timetableExcludedIDs.isEmpty else { return daySnapshot }
return ExportBuilder.history(
dayKey: entry.dayKey,
weeklyTimetable: false,
sessions: sessions,
entries: countEntries,
orderedActions: orderedActions,
excludedActionIDs: timetableExcludedIDs,
math: math,
trimHours: false
)
}
///
private var dayRecordedActions: [Action] {
let range = math.dayRange(forKey: entry.dayKey)
var ids = Set<PersistentIdentifier>()
for session in sessions {
let end = session.endAt ?? .now
if session.startAt < range.upperBound, end > range.lowerBound, let action = session.action {
ids.insert(action.persistentModelID)
}
}
for countEntry in countEntries where range.contains(countEntry.timestamp) {
if let action = countEntry.action { ids.insert(action.persistentModelID) }
}
return orderedActions.filter { ids.contains($0.persistentModelID) }
}
/// / ( )
private var statBars: [ExportSectionData] {
ExportBuilder.stats(
@ -727,12 +771,44 @@ private struct DiarySummaryPage: View {
.sheet(isPresented: $showingCalendarPicker) {
DiaryCalendarPickerSheet(entry: entry)
}
.sheet(isPresented: $showingActionFilter) {
RecordFilterSheet(
title: "타임테이블 필터",
tags: allTags,
actions: dayRecordedActions,
excludedActionIDs: $filterExcluded
)
}
.onChange(of: filterExcluded) { _, newValue in
// + (uuid )
let hidden = orderedActions
.filter { newValue.contains($0.persistentModelID) }
.map(\.uuid.uuidString)
guard hidden != entry.hiddenActionIDs else { return }
entry.hiddenActionIDs = hidden
entry.updatedAt = .now
}
.task(id: entry.calendarEventIDs) {
#if DEBUG
await DiaryCalendarEvents.seedForVerification(entry: entry, math: math)
#endif
calendarBlocks = DiaryCalendarEvents.timetableBlocks(for: entry, math: math)
}
#if DEBUG
.task {
// : -diaryHideFirstAction YES
if UserDefaults.standard.bool(forKey: "diaryHideFirstAction"),
entry.hiddenActionIDs.isEmpty,
let first = dayRecordedActions.first {
entry.hiddenActionIDs = [first.uuid.uuidString]
}
// : -diaryShowActionFilter YES
if UserDefaults.standard.bool(forKey: "diaryShowActionFilter") {
filterExcluded = timetableExcludedIDs
showingActionFilter = true
}
}
#endif
}
///
@ -777,33 +853,62 @@ private struct DiarySummaryPage: View {
}
}
/// + ( )
/// +
/// - : ( , )
/// - :
@ViewBuilder
private func timetableSections(innerWidth: CGFloat) -> some View {
let injected = daySnapshot.injectingCalendarEvents(calendarBlocks)
let injected = timetableSnapshot.injectingCalendarEvents(calendarBlocks)
let hiddenCount = timetableExcludedIDs.count
ForEach(Array(injected.sections.enumerated()), id: \.offset) { _, section in
if case .timetable = section {
ZStack(alignment: .topTrailing) {
ExportSectionView(section: section, innerWidth: innerWidth)
Button {
showingCalendarPicker = true
} label: {
HStack(spacing: 4) {
Image(systemName: "calendar.badge.plus")
.font(.system(size: 12, weight: .semibold))
if !calendarBlocks.isEmpty {
Text(verbatim: "\(calendarBlocks.count)")
.font(.system(size: 11, weight: .bold).monospacedDigit())
HStack(spacing: 6) {
if !dayRecordedActions.isEmpty || hiddenCount > 0 {
Button {
filterExcluded = timetableExcludedIDs
showingActionFilter = true
} label: {
HStack(spacing: 4) {
Image(systemName: "line.3.horizontal.decrease")
.font(.system(size: 12, weight: .semibold))
if hiddenCount > 0 {
Text(verbatim: "\(hiddenCount)")
.font(.system(size: 11, weight: .bold).monospacedDigit())
}
}
.foregroundStyle(hiddenCount > 0 ? AppTheme.yellow : AppTheme.green)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(
(hiddenCount > 0 ? AppTheme.yellow : AppTheme.green).opacity(0.12),
in: Capsule()
)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("타임테이블 필터"))
}
.foregroundStyle(AppTheme.green)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(AppTheme.green.opacity(0.12), in: Capsule())
Button {
showingCalendarPicker = true
} label: {
HStack(spacing: 4) {
Image(systemName: "calendar.badge.plus")
.font(.system(size: 12, weight: .semibold))
if !calendarBlocks.isEmpty {
Text(verbatim: "\(calendarBlocks.count)")
.font(.system(size: 11, weight: .bold).monospacedDigit())
}
}
.foregroundStyle(AppTheme.green)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(AppTheme.green.opacity(0.12), in: Capsule())
}
.buttonStyle(.plain)
.accessibilityLabel(Text("캘린더 일정"))
}
.buttonStyle(.plain)
.padding(10)
.accessibilityLabel(Text("캘린더 일정"))
}
}
}

View File

@ -46,6 +46,21 @@ struct ExportSnapshot {
}
return copy
}
/// .
/// · () .
func replacingTimetableSections(with other: ExportSnapshot) -> ExportSnapshot {
var replacements = other.sections.filter {
if case .timetable = $0 { return true }
return false
}
var copy = self
copy.sections = sections.compactMap { section in
guard case .timetable = section else { return section }
return replacements.isEmpty ? nil : replacements.removeFirst()
}
return copy
}
}
struct ExportHeroStat: Identifiable {

View File

@ -130,7 +130,7 @@ struct HelpView: View {
HelpTopic(
symbol: "checkmark.seal.fill",
title: "목표 종료와 판정",
body: "종료일이 있는 목표는 날짜가 지나면 자동으로 달성 여부가 판정되고, 종료일이 없는 목표는 수동 종료 버튼으로 마칠 수 있어요. 다짐 없이 종료일이 지난 목표는 '달성했나요?' 상태가 되어 직접 선택해요. 완료된 목표는 목록 하단의 완료 보관함에서 볼 수 있어요."
body: "종료일이 있는 목표는 날짜가 지나면 자동으로 달성 여부가 판정되고, 종료일이 없는 목표는 수동 종료 버튼으로 마칠 수 있어요. 판정은 다짐들의 평균 달성률이 목표를 만들 때 정한 '달성 판정 기준'(기본 100%) 이상이면 달성이에요 — 예를 들어 기준을 80%로 두면 조금 못 채워도 달성으로 기록돼요. 다짐 없이 종료일이 지난 목표는 '달성했나요?' 상태가 되어 직접 선택해요. 완료된 목표는 목록 하단의 완료 보관함에서 볼 수 있어요."
),
HelpTopic(
symbol: "pin.fill",
@ -188,6 +188,11 @@ struct HelpView: View {
title: "하루 정리 첫 페이지",
body: "일기 탭 달력에서 날짜를 누르면 그날의 일기가 열려요. 첫 페이지는 오늘 기분·요약·목표 진행 현황·할 일·24시간 타임테이블·기록으로 하루를 정리해 보여줘요. 오른쪽 위 ⋯ 메뉴의 '첫 화면 구성'에서 섹션 표시 여부와 순서를 바꿀 수 있어요."
),
HelpTopic(
symbol: "calendar.badge.plus",
title: "타임테이블에 일정·필터",
body: "첫 페이지 타임테이블 카드 오른쪽 위 캘린더 버튼으로 그날의 캘린더 일정을 골라 함께 표시할 수 있어요. 필터 버튼을 누르면 그날 기록된 행동 중 타임테이블에 보여줄 것만 고를 수 있어요. 두 선택 모두 날짜마다 따로 저장되고, 일기를 내보낼 때도 그대로 반영돼요."
),
HelpTopic(
symbol: "applepencil",
title: "펜슬 노트 페이지",

View File

@ -25,6 +25,9 @@ final class DiaryEntry {
/// ID (EKEvent.eventIdentifier).
/// .
var calendarEventIDs: [String] = []
/// uuid ( ).
/// , .
var hiddenActionIDs: [String] = []
var createdAt: Date = Date()
var updatedAt: Date = Date()