From 93dd3d1d9f89c67028574e774a05e60ab04f55a7 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Mon, 13 Jul 2026 18:24:22 +0900 Subject: [PATCH] feat(diary): per-date action filter for the summary timetable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일기 타임테이블에서 그날 기록된 행동 중 원하는 것만 골라 볼 수 있게. 캘린더 일정 선택과 마찬가지로 날짜별로 저장되고 내보내기에도 반영된다. - DiaryEntry.hiddenActionIDs (행동 uuid 문자열, 날짜별·동기화 안전) - 타임테이블 카드 우상단에 필터 버튼 추가 (숨긴 개수 배지, 캘린더 버튼 옆) - 기존 RecordFilterSheet 재사용 — 그날 기록된 행동만 꼬리표별로 나열 - 필터는 타임테이블에만 적용, 기록 목록·요약 통계는 전체 유지 (ExportSnapshot.replacingTimetableSections로 내보내기도 동일 규칙) - 도움말 갱신: 목표 달성 판정 기준, 일기 타임테이블 일정·필터 항목 - en/ja 번역 추가 (Live Activity의 미번역 문자열 3건도 함께 보충) - 검증용 런치 인자 -diaryShowActionFilter, -diaryHideFirstAction 추가 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/IOS/Localizable.xcstrings | 179 +++++++++++++++++- .../IOS/Views/DiaryExportSheet.swift | 19 +- myApp/HaruDanim/IOS/Views/DiaryView.swift | 139 ++++++++++++-- .../HaruDanim/IOS/Views/ExportImageView.swift | 15 ++ myApp/HaruDanim/IOS/Views/HelpView.swift | 7 +- myApp/HaruDanim/Shared/DiaryModels.swift | 3 + 6 files changed, 341 insertions(+), 21 deletions(-) diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 6fc32b4..299a606 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -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" -} \ No newline at end of file +} diff --git a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift index ab8db99..ee6ea38 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryExportSheet.swift @@ -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 diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift index 41492e3..c30d595 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryView.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -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 = [] + private var math: DayMath { DayMath() } /// 표시할 섹션 (구성 설정의 순서, 숨김 제외) @@ -666,6 +672,44 @@ private struct DiarySummaryPage: View { ) } + /// 이 날짜에 숨기기로 한 행동들 (저장된 uuid 문자열 → 현재 기기의 모델 ID) + private var timetableExcludedIDs: Set { + 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() + 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("캘린더 일정")) } } } diff --git a/myApp/HaruDanim/IOS/Views/ExportImageView.swift b/myApp/HaruDanim/IOS/Views/ExportImageView.swift index 990cb3a..2bc529e 100644 --- a/myApp/HaruDanim/IOS/Views/ExportImageView.swift +++ b/myApp/HaruDanim/IOS/Views/ExportImageView.swift @@ -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 { diff --git a/myApp/HaruDanim/IOS/Views/HelpView.swift b/myApp/HaruDanim/IOS/Views/HelpView.swift index efe3662..6ab5993 100644 --- a/myApp/HaruDanim/IOS/Views/HelpView.swift +++ b/myApp/HaruDanim/IOS/Views/HelpView.swift @@ -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: "펜슬 노트 페이지", diff --git a/myApp/HaruDanim/Shared/DiaryModels.swift b/myApp/HaruDanim/Shared/DiaryModels.swift index a40500c..535a933 100644 --- a/myApp/HaruDanim/Shared/DiaryModels.swift +++ b/myApp/HaruDanim/Shared/DiaryModels.swift @@ -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()