Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
])
}
}
58 changes: 58 additions & 0 deletions DevLog_Unit/Widget/WidgetSnapshotPreferenceStoreTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading