Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions DevLogWidget/Heatmap/HeatmapWidgetEntryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,21 +53,22 @@ 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
)
}
case .systemMedium:
VStack(alignment: .leading, spacing: 8) {
header(title: "이번 분기 히트맵")
WidgetHeatmapPlaceholderGrid(
weekCounts: [5, 5, 5],
months: shape.quarterMonths,
showsMonthTitles: true
)
}
Expand Down
27 changes: 19 additions & 8 deletions DevLogWidget/Heatmap/WidgetHeatmapGrid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
)
Expand Down Expand Up @@ -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)
Expand All @@ -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..<weekCount, id: \.self) { weekIndex in
ForEach(month.weeks) { week in
let day = week.days.first {
Calendar.current.component(.weekday, from: $0.date) == weekday
}

RoundedRectangle(cornerRadius: layout.cellCornerRadius)
.fill(Color.secondary.opacity(opacity(weekday: weekday, weekIndex: weekIndex)))
.fill(fillColor(for: day))
.frame(width: layout.cellSize, height: layout.cellSize)
}
}
Expand All @@ -215,8 +221,13 @@ private struct WidgetHeatmapPlaceholderMonthGrid: View {
}
}

private func opacity(weekday: Int, weekIndex: Int) -> 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:
Expand Down
67 changes: 67 additions & 0 deletions DevLog_Unit/Widget/HeatmapWidgetSnapshotFactoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions DevLog_Unit/Widget/TodayWidgetSnapshotFactoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions DevLog_Unit/Widget/WidgetHeatmapPlaceholderShapeTests.swift
Original file line number Diff line number Diff line change
@@ -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
])
}
}
57 changes: 57 additions & 0 deletions DevLog_Unit/Widget/WidgetSnapshotPreferenceStoreTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// 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 widgetSnapshotPreferenceStore = makeStore()

#expect(widgetSnapshotPreferenceStore.selectedActivityKinds() == Set([.created, .completed, .deleted]))
}

@Test("Heatmap activity kind 설정에 유효하지 않은 값만 있으면 전체 kind를 사용한다")
func heatmap_activity_kind_설정에_유효하지_않은_값만_있으면_전체_kind를_사용한다() {
let widgetSnapshotPreferenceStore = makeStore()

widgetSnapshotPreferenceStore.setHeatmapActivityTypes(["unknown"])

#expect(widgetSnapshotPreferenceStore.selectedActivityKinds() == Set([.created, .completed, .deleted]))
}

@Test("Heatmap activity kind 설정은 유효한 값만 유지한다")
func heatmap_activity_kind_설정은_유효한_값만_유지한다() {
let widgetSnapshotPreferenceStore = makeStore()

widgetSnapshotPreferenceStore.setHeatmapActivityTypes(["created", "unknown", "deleted", "created"])

#expect(widgetSnapshotPreferenceStore.selectedActivityKinds() == Set([.created, .deleted]))
}

@Test("Today display option 설정이 깨져 있으면 기본 옵션을 사용한다")
func today_display_option_설정이_깨져_있으면_기본_옵션을_사용한다() {
let suiteName = "WidgetSnapshotPreferenceStoreTests.\(UUID().uuidString)"
let userDefaults = UserDefaults(suiteName: suiteName) ?? .standard
userDefaults.removePersistentDomain(forName: suiteName)
let widgetSnapshotPreferenceStore = WidgetSnapshotPreferenceStore(userDefaults: userDefaults)

userDefaults.set("invalid", forKey: "Today.dueDateVisibility")
userDefaults.set("invalid", forKey: "Today.focusVisibility")

#expect(widgetSnapshotPreferenceStore.todayDisplayOptions() == .default)
}

private func makeStore() -> WidgetSnapshotPreferenceStore {
let suiteName = "WidgetSnapshotPreferenceStoreTests.\(UUID().uuidString)"
let userDefaults = UserDefaults(suiteName: suiteName) ?? .standard
userDefaults.removePersistentDomain(forName: suiteName)
return WidgetSnapshotPreferenceStore(userDefaults: userDefaults)
}
Comment thread
opficdev marked this conversation as resolved.
Outdated
}
Loading