diff --git a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift index 97a0d08f..10c550c5 100644 --- a/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift +++ b/DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift @@ -53,13 +53,14 @@ struct HeatmapWidgetEntryView: View { @ViewBuilder private var emptyState: some View { + let shape = WidgetHeatmapPlaceholderShape(date: entry.date) + switch widgetFamily { case .systemSmall: VStack(alignment: .leading, spacing: 8) { - Text("이번 달 히트맵") - .font(.headline) + header(title: "이번 달 히트맵") WidgetHeatmapPlaceholderGrid( - weekCounts: [5], + months: shape.currentMonths, showsMonthTitles: false ) } @@ -67,7 +68,7 @@ struct HeatmapWidgetEntryView: View { VStack(alignment: .leading, spacing: 8) { header(title: "이번 분기 히트맵") WidgetHeatmapPlaceholderGrid( - weekCounts: [5, 5, 5], + months: shape.quarterMonths, showsMonthTitles: true ) } diff --git a/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift b/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift index 79e76f33..b21a49c4 100644 --- a/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift +++ b/DevLogWidget/Heatmap/WidgetHeatmapGrid.swift @@ -43,10 +43,12 @@ struct WidgetHeatmapGrid: View { } struct WidgetHeatmapPlaceholderGrid: View { - let weekCounts: [Int] + let months: [WidgetHeatmapPlaceholderMonthShape] let showsMonthTitles: Bool var body: some View { + let weekCounts = months.map(\.weeks.count) + GeometryReader { proxy in let layout = WidgetHeatmapLayout( availableWidth: proxy.size.width, @@ -56,9 +58,9 @@ struct WidgetHeatmapPlaceholderGrid: View { ) HStack(alignment: .top, spacing: layout.monthSpacing) { - ForEach(Array(weekCounts.enumerated()), id: \.offset) { _, weekCount in + ForEach(months) { month in WidgetHeatmapPlaceholderMonthGrid( - weekCount: weekCount, + month: month, layout: layout, showsMonthTitle: showsMonthTitles ) @@ -187,7 +189,7 @@ private enum WidgetHeatmapActivityKind: String { } private struct WidgetHeatmapPlaceholderMonthGrid: View { - let weekCount: Int + let month: WidgetHeatmapPlaceholderMonthShape let layout: WidgetHeatmapLayout let showsMonthTitle: Bool private let orderedWeekdays = Array(1...7) @@ -204,9 +206,13 @@ private struct WidgetHeatmapPlaceholderMonthGrid: View { VStack(alignment: .leading, spacing: layout.cellSpacing) { ForEach(orderedWeekdays, id: \.self) { weekday in HStack(spacing: layout.cellSpacing) { - ForEach(0.. Double { - switch (weekday + weekIndex) % 4 { + private func fillColor(for day: WidgetHeatmapPlaceholderDayShape?) -> Color { + guard let day, day.isVisible else { return .clear } + return Color.secondary.opacity(opacity(for: day)) + } + + private func opacity(for day: WidgetHeatmapPlaceholderDayShape) -> Double { + switch Calendar.current.component(.day, from: day.date) % 4 { case 0: return 1 / 8 case 1: diff --git a/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift b/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift index d6284655..4dbe956a 100644 --- a/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift +++ b/DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift @@ -126,6 +126,73 @@ struct HeatmapWidgetSnapshotFactoryTests { #expect(targetDay.deletedCount == 3) } + @Test("Heatmap 위젯 스냅샷은 분기 시작일은 포함하고 다음 분기 시작일은 제외한다") + func heatmap_위젯_스냅샷은_분기_시작일은_포함하고_다음_분기_시작일은_제외한다() throws { + let calendar = Calendar(identifier: .gregorian) + let quarterStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 1))) + let nextQuarterStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 7, day: 1))) + let quarterLastDate = try #require(calendar.date(byAdding: .second, value: -1, to: nextQuarterStart)) + let factory = HeatmapWidgetSnapshotFactory(calendar: calendar) + + let snapshot = factory.makeSnapshot( + createdTodos: [ + makeTodo(id: "created-quarter-start", createdAt: quarterStart), + makeTodo(id: "created-next-quarter-start", createdAt: nextQuarterStart) + ], + completedTodos: [ + makeTodo( + id: "completed-quarter-last-date", + createdAt: quarterStart, + completedAt: quarterLastDate + ) + ], + deletedTodos: [], + selectedActivityKinds: [.created, .completed], + quarterStart: quarterStart, + now: quarterStart + ) + + let aprilFirst = try #require(day(for: DateComponents(year: 2026, month: 4, day: 1), in: snapshot, calendar: calendar)) + let juneThirtieth = try #require(day(for: DateComponents(year: 2026, month: 6, day: 30), in: snapshot, calendar: calendar)) + + #expect(aprilFirst.createdCount == 1) + #expect(juneThirtieth.completedCount == 1) + #expect(day(for: DateComponents(year: 2026, month: 7, day: 1), in: snapshot, calendar: calendar) == nil) + #expect(snapshot.maxCount == 1) + } + + @Test("Heatmap 위젯 스냅샷은 Q4 분기를 다음 해 1월 전까지 만든다") + func heatmap_위젯_스냅샷은_q4_분기를_다음_해_1월_전까지_만든다() throws { + let calendar = Calendar(identifier: .gregorian) + let q4Date = try #require(calendar.date(from: DateComponents(year: 2026, month: 11, day: 10))) + let octoberStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 10, day: 1))) + let novemberStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 11, day: 1))) + let decemberStart = try #require(calendar.date(from: DateComponents(year: 2026, month: 12, day: 1))) + let decemberLastDate = try #require(calendar.date(from: DateComponents(year: 2026, month: 12, day: 31))) + let nextYearStart = try #require(calendar.date(from: DateComponents(year: 2027, month: 1, day: 1))) + let factory = HeatmapWidgetSnapshotFactory(calendar: calendar) + + let snapshot = factory.makeSnapshot( + createdTodos: [ + makeTodo(id: "created-december-last-date", createdAt: decemberLastDate), + makeTodo(id: "created-next-year-start", createdAt: nextYearStart) + ], + completedTodos: [], + deletedTodos: [], + selectedActivityKinds: [.created], + quarterStart: q4Date, + now: q4Date + ) + + let decemberLastDay = try #require(day(for: DateComponents(year: 2026, month: 12, day: 31), in: snapshot, calendar: calendar)) + + #expect(snapshot.quarterStart == octoberStart) + #expect(snapshot.months.map(\.monthStart) == [octoberStart, novemberStart, decemberStart]) + #expect(decemberLastDay.createdCount == 1) + #expect(day(for: DateComponents(year: 2027, month: 1, day: 1), in: snapshot, calendar: calendar) == nil) + #expect(snapshot.maxCount == 1) + } + private func day( for components: DateComponents, in snapshot: HeatmapWidgetSnapshot, diff --git a/DevLog_Unit/Widget/TodayWidgetSnapshotFactoryTests.swift b/DevLog_Unit/Widget/TodayWidgetSnapshotFactoryTests.swift index c936114b..64077da8 100644 --- a/DevLog_Unit/Widget/TodayWidgetSnapshotFactoryTests.swift +++ b/DevLog_Unit/Widget/TodayWidgetSnapshotFactoryTests.swift @@ -57,6 +57,67 @@ struct TodayWidgetSnapshotFactoryTests { #expect(snapshot.sections[0].items.map(\.title) == ["고정된 할 일"]) } + @Test("Today 위젯 스냅샷은 날짜 경계에 따라 일정 섹션을 구분한다") + func today_위젯_스냅샷은_날짜_경계에_따라_일정_섹션을_구분한다() throws { + let calendar = Calendar(identifier: .gregorian) + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 4, day: 17, hour: 12))) + let yesterday = try #require(calendar.date(byAdding: .day, value: -1, to: now)) + let sevenDaysLater = try #require(calendar.date(byAdding: .day, value: 7, to: now)) + let eightDaysLater = try #require(calendar.date(byAdding: .day, value: 8, to: now)) + let factory = TodayWidgetSnapshotFactory(calendar: calendar) + + let snapshot = factory.makeSnapshot( + todos: try [ + makeTodayTodoItem( + id: "todo-overdue", + number: 1, + title: "지난 일정", + isPinned: false, + dueDate: yesterday + ), + makeTodayTodoItem( + id: "todo-today", + number: 2, + title: "오늘 일정", + isPinned: false, + dueDate: now + ), + makeTodayTodoItem( + id: "todo-seven-days-later", + number: 3, + title: "7일 뒤 일정", + isPinned: false, + dueDate: sevenDaysLater + ), + makeTodayTodoItem( + id: "todo-eight-days-later", + number: 4, + title: "8일 뒤 일정", + isPinned: false, + dueDate: eightDaysLater + ), + makeTodayTodoItem( + id: "todo-unscheduled", + number: 5, + title: "미정 일정", + isPinned: false, + dueDate: nil + ) + ], + displayOptions: .default, + now: now + ) + + #expect(snapshot.totalCount == 5) + #expect(snapshot.overdueCount == 1) + #expect(snapshot.dueSoonCount == 2) + #expect(snapshot.sections.map(\.category) == ["overdue", "dueSoon", "later", "unscheduled"]) + #expect(snapshot.sections[0].items.map(\.title) == ["지난 일정"]) + #expect(snapshot.sections[1].items.map(\.title) == ["오늘 일정", "7일 뒤 일정"]) + #expect(snapshot.sections[2].items.map(\.title) == ["8일 뒤 일정"]) + #expect(snapshot.sections[3].items.map(\.title) == ["미정 일정"]) + } + private func makeTodayTodos( now: Date, calendar: Calendar diff --git a/DevLog_Unit/Widget/WidgetHeatmapPlaceholderShapeTests.swift b/DevLog_Unit/Widget/WidgetHeatmapPlaceholderShapeTests.swift new file mode 100644 index 00000000..9c5dbc15 --- /dev/null +++ b/DevLog_Unit/Widget/WidgetHeatmapPlaceholderShapeTests.swift @@ -0,0 +1,68 @@ +// +// WidgetHeatmapPlaceholderShapeTests.swift +// DevLog_Unit +// +// Created by opfic on 4/30/26. +// + +import Foundation +import Testing +@testable import DevLog + +struct WidgetHeatmapPlaceholderShapeTests { + @Test("Heatmap 위젯 placeholder는 현재 월과 분기의 실제 날짜 위치를 사용한다") + func heatmap_위젯_placeholder는_현재_월과_분기의_실제_날짜_위치를_사용한다() throws { + let calendar = Calendar(identifier: .gregorian) + let date = try #require(calendar.date(from: DateComponents(year: 2026, month: 5, day: 15))) + + let widgetHeatmapPlaceholderShape = WidgetHeatmapPlaceholderShape( + date: date, + calendar: calendar + ) + + #expect(widgetHeatmapPlaceholderShape.currentMonthWeekCounts == [6]) + #expect(widgetHeatmapPlaceholderShape.quarterWeekCounts == [5, 6, 5]) + + let currentMonth = try #require(widgetHeatmapPlaceholderShape.currentMonths.first) + #expect(currentMonth.weeks.count == 6) + #expect(currentMonth.weeks[0].days.map(\.isVisible) == [ + false, + false, + false, + false, + false, + true, + true + ]) + #expect(currentMonth.weeks[5].days.map(\.isVisible) == [ + true, + false, + false, + false, + false, + false, + false + ]) + + let quarterMonths = widgetHeatmapPlaceholderShape.quarterMonths + #expect(quarterMonths.map(\.weeks.count) == [5, 6, 5]) + #expect(quarterMonths[0].weeks[0].days.map(\.isVisible) == [ + false, + false, + false, + true, + true, + true, + true + ]) + #expect(quarterMonths[2].weeks[4].days.map(\.isVisible) == [ + true, + true, + true, + false, + false, + false, + false + ]) + } +} diff --git a/DevLog_Unit/Widget/WidgetSnapshotPreferenceStoreTests.swift b/DevLog_Unit/Widget/WidgetSnapshotPreferenceStoreTests.swift new file mode 100644 index 00000000..cc6a8612 --- /dev/null +++ b/DevLog_Unit/Widget/WidgetSnapshotPreferenceStoreTests.swift @@ -0,0 +1,58 @@ +// +// WidgetSnapshotPreferenceStoreTests.swift +// DevLog_Unit +// +// Created by opfic on 4/30/26. +// + +import Foundation +import Testing +@testable import DevLog + +struct WidgetSnapshotPreferenceStoreTests { + @Test("Heatmap activity kind 설정이 비어 있으면 전체 kind를 사용한다") + func heatmap_activity_kind_설정이_비어_있으면_전체_kind를_사용한다() { + let fixture = makeFixture() + + #expect(fixture.widgetSnapshotPreferenceStore.selectedActivityKinds() == Set([.created, .completed, .deleted])) + } + + @Test("Heatmap activity kind 설정에 유효하지 않은 값만 있으면 전체 kind를 사용한다") + func heatmap_activity_kind_설정에_유효하지_않은_값만_있으면_전체_kind를_사용한다() { + let fixture = makeFixture() + + fixture.widgetSnapshotPreferenceStore.setHeatmapActivityTypes(["unknown"]) + + #expect(fixture.widgetSnapshotPreferenceStore.selectedActivityKinds() == Set([.created, .completed, .deleted])) + } + + @Test("Heatmap activity kind 설정은 유효한 값만 유지한다") + func heatmap_activity_kind_설정은_유효한_값만_유지한다() { + let fixture = makeFixture() + + fixture.widgetSnapshotPreferenceStore.setHeatmapActivityTypes(["created", "unknown", "deleted", "created"]) + + #expect(fixture.widgetSnapshotPreferenceStore.selectedActivityKinds() == Set([.created, .deleted])) + } + + @Test("Today display option 설정이 깨져 있으면 기본 옵션을 사용한다") + func today_display_option_설정이_깨져_있으면_기본_옵션을_사용한다() { + let fixture = makeFixture() + + fixture.userDefaults.set("invalid", forKey: "Today.dueDateVisibility") + fixture.userDefaults.set("invalid", forKey: "Today.focusVisibility") + + #expect(fixture.widgetSnapshotPreferenceStore.todayDisplayOptions() == .default) + } + + private func makeFixture() -> ( + widgetSnapshotPreferenceStore: WidgetSnapshotPreferenceStore, + userDefaults: UserDefaults + ) { + let suiteName = "WidgetSnapshotPreferenceStoreTests.\(UUID().uuidString)" + let userDefaults = UserDefaults(suiteName: suiteName) ?? .standard + userDefaults.removePersistentDomain(forName: suiteName) + let widgetSnapshotPreferenceStore = WidgetSnapshotPreferenceStore(userDefaults: userDefaults) + return (widgetSnapshotPreferenceStore, userDefaults) + } +} diff --git a/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift b/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift index 0323b4a2..1723a527 100644 --- a/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift +++ b/DevLog_Unit/Widget/WidgetSyncEventHandlerTests.swift @@ -51,6 +51,57 @@ struct WidgetSyncEventHandlerTests { _ = fixture.handler } + @Test("Today 스냅샷 조회 실패는 Heatmap 스냅샷 갱신을 막지 않는다") + func today_스냅샷_조회_실패는_heatmap_스냅샷_갱신을_막지_않는다() async throws { + let calendar = Calendar.current + let now = Date() + let quarterStart = calendar.startOfQuarter(for: now) + let fixture = makeFixture(calendar: calendar) + + await fixture.todoRepository.setTodos( + createdTodos: [ + makeTodo(id: "created", createdAt: now) + ], + completedTodos: [ + makeTodo(id: "completed", createdAt: quarterStart, completedAt: now) + ], + deletedTodos: [ + makeTodo(id: "deleted", createdAt: quarterStart, deletedAt: now) + ] + ) + await fixture.todoRepository.setFailingSortTargets([.dueDate]) + + fixture.bus.publish(.syncRequested) + + let heatmapSnapshot = try await loadHeatmapSnapshot(from: fixture.snapshotStore) + + #expect(heatmapSnapshot.maxCount == 3) + #expect(try fixture.snapshotStore.loadTodaySnapshot() == nil) + _ = fixture.handler + } + + @Test("Heatmap 스냅샷 조회 실패는 Today 스냅샷 갱신을 막지 않는다") + func heatmap_스냅샷_조회_실패는_today_스냅샷_갱신을_막지_않는다() async throws { + let calendar = Calendar.current + let now = Date() + let fixture = makeFixture(calendar: calendar) + + await fixture.todoRepository.setTodos( + todayTodosWithDueDate: [ + makeTodo(id: "today", createdAt: now, dueDate: now) + ] + ) + await fixture.todoRepository.setFailingSortTargets([.createdAt]) + + fixture.bus.publish(.syncRequested) + + let todaySnapshot = try await loadTodaySnapshot(from: fixture.snapshotStore) + + #expect(todaySnapshot.totalCount == 1) + #expect(try fixture.snapshotStore.loadHeatmapSnapshot() == nil) + _ = fixture.handler + } + private func makeFixture( calendar: Calendar ) -> ( @@ -139,6 +190,7 @@ struct WidgetSyncEventHandlerTests { private actor WidgetSyncTodoRepositorySpy: TodoRepository { private var queries = [TodoQuery]() + private var failingSortTargets = Set() private var todayTodosWithDueDate = [Todo]() private var todayTodosWithoutDueDate = [Todo]() private var createdTodos = [Todo]() @@ -159,9 +211,17 @@ private actor WidgetSyncTodoRepositorySpy: TodoRepository { self.deletedTodos = deletedTodos } + func setFailingSortTargets(_ failingSortTargets: Set) { + self.failingSortTargets = failingSortTargets + } + func fetchTodos(_ query: TodoQuery, cursor: TodoCursor?) async throws -> TodoPage { queries.append(query) + if failingSortTargets.contains(query.sortTarget) { + throw DataError.invalidData("WidgetSyncTodoRepositorySpy.fetchTodos failed") + } + let items: [Todo] switch query.sortTarget { case .dueDate: diff --git a/DevLog/Domain/Extension/Calendar.swift b/WidgetShared/Calendar.swift similarity index 100% rename from DevLog/Domain/Extension/Calendar.swift rename to WidgetShared/Calendar.swift diff --git a/WidgetShared/WidgetHeatmapPlaceholderShape.swift b/WidgetShared/WidgetHeatmapPlaceholderShape.swift new file mode 100644 index 00000000..0fd803ee --- /dev/null +++ b/WidgetShared/WidgetHeatmapPlaceholderShape.swift @@ -0,0 +1,126 @@ +// +// WidgetHeatmapPlaceholderShape.swift +// DevLog +// +// Created by opfic on 4/30/26. +// + +import Foundation + +struct WidgetHeatmapPlaceholderShape { + let currentMonths: [WidgetHeatmapPlaceholderMonthShape] + let quarterMonths: [WidgetHeatmapPlaceholderMonthShape] + + var currentMonthWeekCounts: [Int] { + currentMonths.map(\.weeks.count) + } + + var quarterWeekCounts: [Int] { + quarterMonths.map(\.weeks.count) + } + + init( + date: Date = Date(), + calendar: Calendar = .current + ) { + let quarterStart = calendar.startOfQuarter(for: date) + let monthStarts = (0..<3).compactMap { + calendar.date(byAdding: .month, value: $0, to: quarterStart) + } + let widgetHeatmapPlaceholderMonthShapes = monthStarts.map { + Self.makeMonth(monthStart: $0, calendar: calendar) + } + + if let currentMonth = widgetHeatmapPlaceholderMonthShapes.first(where: { + calendar.isDate($0.monthStart, equalTo: date, toGranularity: .month) + }) { + currentMonths = [currentMonth] + } else { + currentMonths = Array(widgetHeatmapPlaceholderMonthShapes.prefix(1)) + } + + quarterMonths = widgetHeatmapPlaceholderMonthShapes + } + + private static func makeMonth( + monthStart: Date, + calendar: Calendar + ) -> WidgetHeatmapPlaceholderMonthShape { + guard let monthInterval = calendar.dateInterval(of: .month, for: monthStart), + let monthLastDay = calendar.date(byAdding: .day, value: -1, to: monthInterval.end), + let firstWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthInterval.start), + let lastWeekInterval = calendar.dateInterval(of: .weekOfYear, for: monthLastDay) else { + return WidgetHeatmapPlaceholderMonthShape(monthStart: monthStart, weeks: []) + } + + var weeks = [WidgetHeatmapPlaceholderWeekShape]() + var cursor = firstWeekInterval.start + + while cursor < lastWeekInterval.end { + weeks.append( + WidgetHeatmapPlaceholderWeekShape( + id: weeks.count, + days: makeDays( + weekStart: cursor, + monthStart: monthStart, + calendar: calendar + ) + ) + ) + + guard let nextWeek = calendar.date(byAdding: .weekOfYear, value: 1, to: cursor) else { + break + } + cursor = nextWeek + } + + return WidgetHeatmapPlaceholderMonthShape(monthStart: monthStart, weeks: weeks) + } + + private static func makeDays( + weekStart: Date, + monthStart: Date, + calendar: Calendar + ) -> [WidgetHeatmapPlaceholderDayShape] { + var days = [WidgetHeatmapPlaceholderDayShape]() + var cursor = weekStart + + for _ in 0..<7 { + let normalizedDate = calendar.startOfDay(for: cursor) + days.append( + WidgetHeatmapPlaceholderDayShape( + date: normalizedDate, + isVisible: calendar.isDate( + normalizedDate, + equalTo: monthStart, + toGranularity: .month + ) + ) + ) + + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: cursor) else { + break + } + cursor = nextDay + } + + return days + } +} + +struct WidgetHeatmapPlaceholderMonthShape: Identifiable, Hashable { + var id: Date { monthStart } + let monthStart: Date + let weeks: [WidgetHeatmapPlaceholderWeekShape] +} + +struct WidgetHeatmapPlaceholderWeekShape: Identifiable, Hashable { + let id: Int + let days: [WidgetHeatmapPlaceholderDayShape] +} + +struct WidgetHeatmapPlaceholderDayShape: Identifiable, Hashable { + var id: Date { date } + let date: Date + let isVisible: Bool +}