diff --git a/AGENTS.md b/AGENTS.md index 55f640f9..6895f826 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,16 @@ These may proceed after inspection when they do not change architecture meaning: - Do not claim architecture work is complete without checking the diff scope. - Do not spend time on unrelated generated project or lockfile churn. Keep generated workspace/project and `Package.resolved` changes out of source control unless they are part of an explicitly approved dependency-lock policy. +## Git and commit rules + +- Commit messages must start with a short prefix used by recent local commits, such as `feat`, `fix`, `refactor`, `chore`, `test`, `docs`, `ui`, or `rollback`. +- Write commit message prose in Korean. +- Keep implementation names such as `ToastPresenter`, `toastHost`, `MainView`, `DevLogPresentation`, file paths, commands, branch names, and commit hashes in their original form. +- Do not translate implementation names into Korean unless the user explicitly asks for a user-facing Korean label. +- Do not write a commit message body. +- When checking recent commit-message style, do not infer local commit style from GitHub merge or squash-merge subjects such as `[#123] ... (#456)`. +- For squash-merge commits, inspect the commit body and use the individual bullet commit messages as the style reference. + ## Canonical project rules - DevLog-specific working rules belong in this repository, not in global agent memory. diff --git a/Application/DevLogApp/Sources/App/DevLogApp.swift b/Application/DevLogApp/Sources/App/DevLogApp.swift index 3b434735..2d8d8056 100644 --- a/Application/DevLogApp/Sources/App/DevLogApp.swift +++ b/Application/DevLogApp/Sources/App/DevLogApp.swift @@ -18,6 +18,7 @@ struct DevLogApp: App { @Environment(\.diContainer) var container: DIContainer @Environment(\.scenePhase) var scenePhase @State private var windowEvent = TodoEditorWindowEvent() + @State private var syncDate = Date() init() { AppAssembler().assemble(AppDIContainer.shared) @@ -38,7 +39,23 @@ struct DevLogApp: App { .autocorrectionDisabled() .onChange(of: scenePhase) { _, phase in guard phase == .background else { return } - container.resolve(WidgetSyncEventBus.self).publish(.syncRequested) + let now = Date() + let bus = container.resolve(WidgetSyncEventBus.self) + + // 위젯 갱신은 앱 실행 시 로그인 세션 흐름에서 한 번 요청된다. (WidgetSessionSyncHandler.swift:47) + // Todo 변경 성공 시에는 즉시 fetch하지 않고 WidgetSyncEventBus에 갱신 요청만 남긴다. + // 따라서 같은 날의 백그라운드 진입은 저장된 요청이 있을 때만 기존 syncRequested 흐름을 실행한다. + // 앱이 실행된 상태로 날짜가 넘어간 경우에는 Today widget의 분류 기준일 자체가 바뀌므로, + // 콘텐츠 변경 여부와 관계없이 기존 syncRequested 흐름을 즉시 허용한다. + guard Calendar.current.isDate(syncDate, inSameDayAs: now) else { + syncDate = now + _ = bus.confirmRequest() + bus.publish(.syncRequested) + return + } + + guard bus.confirmRequest() else { return } + bus.publish(.syncRequested) } } WindowGroup(id: TodoEditorWindowValue.sceneId, for: TodoEditorWindowValue.self) { value in diff --git a/Application/DevLogData/Sources/Protocol/WidgetConfigurationProvider.swift b/Application/DevLogData/Sources/Protocol/WidgetConfigurationProvider.swift new file mode 100644 index 00000000..395c8749 --- /dev/null +++ b/Application/DevLogData/Sources/Protocol/WidgetConfigurationProvider.swift @@ -0,0 +1,10 @@ +// +// WidgetConfigurationProvider.swift +// DevLogData +// +// Created by opfic on 6/28/26. +// + +public protocol WidgetConfigurationProvider { + func currentWidgetKinds() async throws -> Set +} diff --git a/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift index 475e3091..9e6f58a5 100644 --- a/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift +++ b/Application/DevLogData/Sources/Repository/TodoRepositoryImpl.swift @@ -143,7 +143,7 @@ final class TodoRepositoryImpl: TodoRepository { private func upsertTodo(_ todoRequest: TodoRequest) async throws { do { try await todoService.upsertTodo(request: todoRequest) - widgetSyncEventBus.publish(.syncRequested) + widgetSyncEventBus.request() } catch { throw error.toDomain() } @@ -152,7 +152,7 @@ final class TodoRepositoryImpl: TodoRepository { func deleteTodo(_ todoId: String) async throws { do { try await todoService.deleteTodo(todoId: todoId) - widgetSyncEventBus.publish(.syncRequested) + widgetSyncEventBus.request() todoMutationEventBus.publish(.deleted(todoId)) } catch { throw error.toDomain() @@ -162,7 +162,7 @@ final class TodoRepositoryImpl: TodoRepository { func undoDeleteTodo(_ todoId: String) async throws { do { try await todoService.undoDeleteTodo(todoId: todoId) - widgetSyncEventBus.publish(.syncRequested) + widgetSyncEventBus.request() todoMutationEventBus.publish(.restored(todoId)) } catch { throw error.toDomain() diff --git a/Application/DevLogData/Sources/Widget/WidgetSyncEventBus.swift b/Application/DevLogData/Sources/Widget/WidgetSyncEventBus.swift index 02cf4977..f9b2e9e4 100644 --- a/Application/DevLogData/Sources/Widget/WidgetSyncEventBus.swift +++ b/Application/DevLogData/Sources/Widget/WidgetSyncEventBus.swift @@ -9,5 +9,7 @@ import Combine public protocol WidgetSyncEventBus { func publish(_ event: WidgetSyncEvent) + func request() + func confirmRequest() -> Bool func observe() -> AnyPublisher } diff --git a/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift b/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift index 64d45eb3..aa3ffffc 100644 --- a/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift +++ b/Application/DevLogData/Tests/Repository/TodoRepositoryImplTests.swift @@ -13,8 +13,8 @@ import DevLogDomain @testable import DevLogData struct TodoRepositoryImplTests { - @Test("Todo 변경 성공 시 위젯 동기화와 mutation 이벤트를 발행한다") - func todo_변경_성공_시_위젯_동기화와_mutation_이벤트를_발행한다() async throws { + @Test("Todo 변경 성공 시 위젯 동기화 요청과 mutation 이벤트를 발행한다") + func todo_변경_성공_시_위젯_동기화_요청과_mutation_이벤트를_발행한다() async throws { let fixture = makeFixture() let todo = makeTodo() @@ -22,8 +22,7 @@ struct TodoRepositoryImplTests { try await fixture.repository.deleteTodo(todo.id) try await fixture.repository.undoDeleteTodo(todo.id) - let events = fixture.widgetSyncEventBus.events - #expect(events == [.syncRequested, .syncRequested, .syncRequested]) + #expect(fixture.widgetSyncEventBus.requestCallCount == 3) let mutationEvents = fixture.todoMutationEventBus.publishedEvents() #expect(mutationEvents == [.updated(todo.id), .deleted(todo.id), .restored(todo.id)]) @@ -55,8 +54,7 @@ struct TodoRepositoryImplTests { #expect(error as? TodoRepositoryImplTestsError == .serviceFailed) } - let syncEvents = fixture.widgetSyncEventBus.events - #expect(syncEvents.isEmpty) + #expect(fixture.widgetSyncEventBus.requestCallCount == 0) let mutationEvents = fixture.todoMutationEventBus.publishedEvents() #expect(mutationEvents.isEmpty) @@ -177,14 +175,20 @@ private final class TodoRepositoryMemoryCacheStoreSpy: MemoryCacheStore { } private final class WidgetSyncEventBusSpy: WidgetSyncEventBus { - private(set) var events = [WidgetSyncEvent]() + private(set) var requestCallCount = 0 - func observe() -> AnyPublisher { - Empty().eraseToAnyPublisher() + func publish(_ event: WidgetSyncEvent) { } + + func request() { + requestCallCount += 1 } - func publish(_ event: WidgetSyncEvent) { - events.append(event) + func confirmRequest() -> Bool { + false + } + + func observe() -> AnyPublisher { + Empty().eraseToAnyPublisher() } } diff --git a/Application/DevLogData/Tests/Repository/UserPreferencesRepositoryImplTests.swift b/Application/DevLogData/Tests/Repository/UserPreferencesRepositoryImplTests.swift index 65a0ea8b..29477917 100644 --- a/Application/DevLogData/Tests/Repository/UserPreferencesRepositoryImplTests.swift +++ b/Application/DevLogData/Tests/Repository/UserPreferencesRepositoryImplTests.swift @@ -112,6 +112,12 @@ private final class WidgetSyncEventBusSpy: WidgetSyncEventBus { events.append(event) } + func request() { } + + func confirmRequest() -> Bool { + false + } + func observe() -> AnyPublisher { Empty().eraseToAnyPublisher() } diff --git a/Application/DevLogWidget/Sources/Widget/WidgetAssembler.swift b/Application/DevLogWidget/Sources/Widget/WidgetAssembler.swift index 4c539136..7d95655d 100644 --- a/Application/DevLogWidget/Sources/Widget/WidgetAssembler.swift +++ b/Application/DevLogWidget/Sources/Widget/WidgetAssembler.swift @@ -20,6 +20,9 @@ public final class WidgetAssembler: Assembler { container.register(WidgetSyncEventBus.self) { WidgetSyncEventBusImpl() } + container.register(WidgetConfigurationProvider.self) { + WidgetConfigurationProviderImpl() + } container.register(WidgetSharedDefaultsStore.self) { WidgetSharedDefaultsStore() } @@ -41,7 +44,8 @@ public final class WidgetAssembler: Assembler { WidgetSyncEventHandler( eventBus: container.resolve(WidgetSyncEventBus.self), repository: container.resolve(WidgetTodoSnapshotRepository.self), - snapshotUpdater: container.resolve(WidgetSnapshotUpdater.self) + snapshotUpdater: container.resolve(WidgetSnapshotUpdater.self), + configurationProvider: container.resolve(WidgetConfigurationProvider.self) ) } container.register(WidgetSessionSyncHandler.self) { diff --git a/Application/DevLogWidget/Sources/Widget/WidgetConfigurationProviderImpl.swift b/Application/DevLogWidget/Sources/Widget/WidgetConfigurationProviderImpl.swift new file mode 100644 index 00000000..93aba401 --- /dev/null +++ b/Application/DevLogWidget/Sources/Widget/WidgetConfigurationProviderImpl.swift @@ -0,0 +1,25 @@ +// +// WidgetConfigurationProviderImpl.swift +// DevLogWidget +// +// Created by opfic on 6/28/26. +// + +import WidgetKit +import DevLogData + +public final class WidgetConfigurationProviderImpl: WidgetConfigurationProvider { + public init() { } + + public func currentWidgetKinds() async throws -> Set { + try await withCheckedThrowingContinuation { continuation in + WidgetCenter.shared.getCurrentConfigurations { result in + continuation.resume( + with: result.map { configurations in + Set(configurations.map(\.kind)) + } + ) + } + } + } +} diff --git a/Application/DevLogWidget/Sources/Widget/WidgetSyncEventBusImpl.swift b/Application/DevLogWidget/Sources/Widget/WidgetSyncEventBusImpl.swift index 48abc23f..dfaa4a8b 100644 --- a/Application/DevLogWidget/Sources/Widget/WidgetSyncEventBusImpl.swift +++ b/Application/DevLogWidget/Sources/Widget/WidgetSyncEventBusImpl.swift @@ -6,10 +6,13 @@ // import Combine +import Foundation import DevLogData public final class WidgetSyncEventBusImpl: WidgetSyncEventBus { private let subject = PassthroughSubject() + private let lock = NSLock() + private var isRequested = false public init() { } @@ -17,6 +20,21 @@ public final class WidgetSyncEventBusImpl: WidgetSyncEventBus { subject.send(event) } + public func request() { + lock.lock() + defer { lock.unlock() } + isRequested = true + } + + public func confirmRequest() -> Bool { + lock.lock() + defer { lock.unlock() } + + guard isRequested else { return false } + isRequested = false + return true + } + public func observe() -> AnyPublisher { subject.eraseToAnyPublisher() } diff --git a/Application/DevLogWidget/Sources/Widget/WidgetSyncEventHandler.swift b/Application/DevLogWidget/Sources/Widget/WidgetSyncEventHandler.swift index 39a7b1ec..02e48fb9 100644 --- a/Application/DevLogWidget/Sources/Widget/WidgetSyncEventHandler.swift +++ b/Application/DevLogWidget/Sources/Widget/WidgetSyncEventHandler.swift @@ -9,10 +9,12 @@ import Combine import Foundation import DevLogCore import DevLogData +import DevLogWidgetCore public final class WidgetSyncEventHandler { private let repository: WidgetTodoSnapshotRepository private let snapshotUpdater: WidgetSnapshotUpdater + private let configurationProvider: WidgetConfigurationProvider private let pageSize = 100 private let logger = Logger(category: "WidgetSyncEventHandler") private var cancellables = Set() @@ -20,10 +22,12 @@ public final class WidgetSyncEventHandler { public init( eventBus: WidgetSyncEventBus, repository: WidgetTodoSnapshotRepository, - snapshotUpdater: WidgetSnapshotUpdater + snapshotUpdater: WidgetSnapshotUpdater, + configurationProvider: WidgetConfigurationProvider ) { self.repository = repository self.snapshotUpdater = snapshotUpdater + self.configurationProvider = configurationProvider eventBus.observe() .sink { [weak self] event in @@ -40,13 +44,32 @@ private extension WidgetSyncEventHandler { Task { [weak self] in guard let self else { return } let now = Date() - async let todaySnapshot: Void = updateTodayWidgetSnapshot(now: now) - async let heatmapSnapshot: Void = updateHeatmapWidgetSnapshot(now: now) - _ = await (todaySnapshot, heatmapSnapshot) + let targets = await enabledSnapshotTargets() + await withTaskGroup(of: Void.self) { group in + if targets.contains(.today) { + group.addTask { await self.updateTodayWidgetSnapshot(now: now) } + } + if targets.contains(.heatmap) { + group.addTask { await self.updateHeatmapWidgetSnapshot(now: now) } + } + } } } } + func enabledSnapshotTargets() async -> Set { + do { + let kinds = try await configurationProvider.currentWidgetKinds() + return WidgetSnapshotTarget.targets(for: kinds) + } catch { + logger.error( + "Failed to fetch current widget configurations.", + error: error + ) + return Set(WidgetSnapshotTarget.allCases) + } + } + func updateTodayWidgetSnapshot(now: Date) async { do { async let todosWithDueDate = fetchTodayTodos( @@ -143,3 +166,19 @@ private extension WidgetSyncEventHandler { ) } } + +private enum WidgetSnapshotTarget: CaseIterable { + case today + case heatmap + + static func targets(for kinds: Set) -> Set { + var targets = Set() + if kinds.contains(WidgetKind.todayTodo) { + targets.insert(.today) + } + if kinds.contains(WidgetKind.heatmap) { + targets.insert(.heatmap) + } + return targets + } +} diff --git a/Application/DevLogWidget/Tests/Handler/WidgetSessionSyncHandlerTests.swift b/Application/DevLogWidget/Tests/Handler/WidgetSessionSyncHandlerTests.swift index 45c647dc..f433ebb9 100644 --- a/Application/DevLogWidget/Tests/Handler/WidgetSessionSyncHandlerTests.swift +++ b/Application/DevLogWidget/Tests/Handler/WidgetSessionSyncHandlerTests.swift @@ -75,6 +75,12 @@ private final class WidgetSyncEventBusSpy: WidgetSyncEventBus { events.append(event) } + func request() { } + + func confirmRequest() -> Bool { + false + } + func observe() -> AnyPublisher { Empty().eraseToAnyPublisher() } diff --git a/Application/DevLogWidget/Tests/Widget/WidgetSyncEventBusTests.swift b/Application/DevLogWidget/Tests/Widget/WidgetSyncEventBusTests.swift index 188aff90..8a70154e 100644 --- a/Application/DevLogWidget/Tests/Widget/WidgetSyncEventBusTests.swift +++ b/Application/DevLogWidget/Tests/Widget/WidgetSyncEventBusTests.swift @@ -25,4 +25,16 @@ struct WidgetSyncEventBusTests { #expect(receivedEvents == [.syncRequested]) _ = cancellable } + + @Test("WidgetSyncEventBus는 요청 여부를 한 번만 소비한다") + func widgetSyncEventBus는_요청_여부를_한_번만_소비한다() { + let bus = WidgetSyncEventBusImpl() + + #expect(bus.confirmRequest() == false) + + bus.request() + + #expect(bus.confirmRequest()) + #expect(bus.confirmRequest() == false) + } } diff --git a/Application/DevLogWidget/Tests/Widget/WidgetSyncEventHandlerTests.swift b/Application/DevLogWidget/Tests/Widget/WidgetSyncEventHandlerTests.swift index b7f729e3..ec8c4964 100644 --- a/Application/DevLogWidget/Tests/Widget/WidgetSyncEventHandlerTests.swift +++ b/Application/DevLogWidget/Tests/Widget/WidgetSyncEventHandlerTests.swift @@ -9,6 +9,7 @@ import Foundation import Testing import DevLogCore import DevLogData +import DevLogWidgetCore @testable import DevLogWidget struct WidgetSyncEventHandlerTests { @@ -93,6 +94,28 @@ struct WidgetSyncEventHandlerTests { _ = fixture.handler } + @Test("Today 위젯만 설정되어 있으면 Today 스냅샷만 갱신한다") + func today_위젯만_설정되어_있으면_today_스냅샷만_갱신한다() async throws { + let now = Date() + let fixture = makeFixture(widgetKinds: [WidgetKind.todayTodo]) + + await fixture.repository.setTodos( + todayTodosWithDueDate: [ + makeTodo(id: "today", createdAt: now, dueDate: now) + ] + ) + + fixture.bus.publish(.syncRequested) + + try await waitUntil { + fixture.snapshotUpdater.hasTodayUpdate + } + + #expect(fixture.snapshotUpdater.todayUpdates.first?.todos.map(\.id) == ["today"]) + #expect(fixture.snapshotUpdater.heatmapUpdates.isEmpty) + #expect(Set(await fixture.repository.calledCalls().map(\.sortTarget)) == Set([.dueDate, .updatedAt])) + _ = fixture.handler + } @Test("Heatmap 스냅샷 조회 실패는 Today 스냅샷 갱신을 막지 않는다") func heatmap_스냅샷_조회_실패는_today_스냅샷_갱신을_막지_않는다() async throws { let now = Date() @@ -116,14 +139,18 @@ struct WidgetSyncEventHandlerTests { _ = fixture.handler } - private func makeFixture() -> Fixture { + private func makeFixture( + widgetKinds: Set = [WidgetKind.todayTodo, WidgetKind.heatmap] + ) -> Fixture { let bus = WidgetSyncEventBusImpl() let repository = WidgetTodoSnapshotRepositorySpy() let snapshotUpdater = WidgetSnapshotUpdaterSpy() + let configurationProvider = WidgetConfigurationProviderSpy(widgetKinds: widgetKinds) let handler = WidgetSyncEventHandler( eventBus: bus, repository: repository, - snapshotUpdater: snapshotUpdater + snapshotUpdater: snapshotUpdater, + configurationProvider: configurationProvider ) return Fixture( @@ -161,6 +188,13 @@ private struct Fixture { let handler: WidgetSyncEventHandler } +private struct WidgetConfigurationProviderSpy: WidgetConfigurationProvider { + let widgetKinds: Set + func currentWidgetKinds() async throws -> Set { + widgetKinds + } +} + private actor WidgetTodoSnapshotRepositorySpy: WidgetTodoSnapshotRepository { struct Call { let sortTarget: TodoQuery.SortTarget