From c92e7d1d887919da5e760e69c17b11a5dd3ce607 Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sun, 16 Aug 2026 11:23:55 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[#85]=20refactor:=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EC=9E=84=EA=B3=84=EA=B0=92(3/7/30=EC=9D=BC)=EC=9D=84=20SecretE?= =?UTF-8?q?xpiryPolicy=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 만료 관련 기간 상수가 네 곳(SecretExpiryStatus의 배지 임계값, 만료 알림 스케줄러의 daysBeforeExpiry, ExpiryBucket의 Expired 탭 섹션 경계, SecretQuery.Collection의 쿼리 window)에 각각 리터럴로 적혀 있었다. 값이 우연히 일치할 뿐이라 한 곳만 바뀌면 나머지가 조용히 어긋난다. DVDomain에 SecretExpiryPolicy를 두고 네 소비처가 값만 공유하도록 정리했다. 배지·섹션 분류처럼 목적이 다른 판정 로직은 그대로 각자 소유한다 — 통합한 것은 숫자뿐이다. 배지 표시 자체(critical/upcoming 2단계, 아이콘 형태로 색맹 대응)는 이미 구현되어 있어 새로 만들지 않았다. Co-Authored-By: Claude Sonnet 5 --- .../Sources/Entity/SecretExpiryPolicy.swift | 26 +++++++++++++++++++ .../Repository/Model/SecretQuery.swift | 11 +++----- ...SecretExpiryNotificationsUseCaseImpl.swift | 5 +++- .../Core/Entity/SecretExpiryPolicyTests.swift | 15 +++++++++++ .../Core/Repository/SecretQueryTests.swift | 10 +++---- .../Features/SecretList/SecretListView.swift | 12 ++++++--- .../Sources/Support/SecretExpiryStatus.swift | 7 ++--- .../SecretList/SecretListFeatureTests.swift | 2 +- 8 files changed, 67 insertions(+), 21 deletions(-) create mode 100644 Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift create mode 100644 Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift diff --git a/Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift b/Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift new file mode 100644 index 00000000..e7a7b8f7 --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift @@ -0,0 +1,26 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// "만료가 임박했다"를 판단하는 기간 상수의 단일 소스. +/// +/// 같은 개념(3일 / 7일 / 30일)이 배지 표시(`SecretExpiryStatus`), 만료 알림 스케줄 +/// (`ScheduleSecretExpiryNotificationsUseCaseImpl`), Expired 탭 섹션 분류(`ExpiryBucket`), +/// Expired 탭 쿼리 범위(`SecretQuery.Collection.expiringWindow`)에 각각 필요하다. +/// 값을 여기 하나로 모아두지 않으면 한 곳만 바뀌었을 때 나머지가 조용히 어긋난다 +/// (예: 카드에 찍힌 개수와 목록에 뜨는 개수가 달라짐). +/// +/// 판정 로직(무엇을 배지로 보여줄지, 무엇을 섹션으로 나눌지)은 목적마다 달라 여기서 +/// 통합하지 않는다. 여기서는 값만 소유한다. +public enum SecretExpiryPolicy { + + /// 즉시 조치가 필요한 단계로 볼 기간(일). 이미 지난 경우도 이 안에 포함해 판정하는 것은 + /// 소비자(`SecretExpiryStatus`)의 정책이다. + public static let criticalWindowDays = 3 + + /// 아직 조치할 시간이 있는 예고 단계로 볼 기간(일). + public static let upcomingWindowDays = 7 + + /// Expired 탭에 "만료 예정"으로 함께 보여줄 범위(일). + public static let listingWindowDays = 30 +} diff --git a/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift b/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift index e69bc2c0..2c67cec8 100644 --- a/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift +++ b/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift @@ -38,19 +38,16 @@ extension SecretQuery { case deleted case project(id: UUID) - /// Notice에 담을 "만료 임박" 기간(일). 목록 행 배지의 upcoming window(7일)와 같은 기준을 써야 + /// Notice에 담을 "만료 임박" 기간(일). 목록 행 배지의 upcoming window와 같은 기준을 써야 /// 사이드바 카드 숫자와 배지가 뜨는 시크릿 집합이 어긋나지 않는다. - public static let noticeWindowDays = 7 + public static let noticeWindowDays = SecretExpiryPolicy.upcomingWindowDays /// `referenceDate`로부터 `noticeWindowDays`만큼 민 시각. public static func noticeWindowEnd(from referenceDate: Date) -> Date { referenceDate.addingTimeInterval(TimeInterval(noticeWindowDays) * 86_400) } - /// Expired 범위에 함께 담을 "만료 예정" 기간(일). - public static let expiringSoonWindowDays = 30 - - /// "이미 지남 + `expiringSoonWindowDays`일 이내 만료 예정"을 한 번에 담는 컬렉션. + /// "이미 지남 + `SecretExpiryPolicy.listingWindowDays`일 이내 만료 예정"을 한 번에 담는 컬렉션. /// /// `expired` predicate는 `expiresAt < referenceDate` 단일 비교라, 기준일을 window만큼 /// 미래로 밀어서 두 범위를 함께 가져온다. 목록 조회와 사이드바 개수 집계가 **같은 함수**를 @@ -59,7 +56,7 @@ extension SecretQuery { public static func expiringWindow(from referenceDate: Date) -> Self { .expired( referenceDate: referenceDate.addingTimeInterval( - TimeInterval(expiringSoonWindowDays) * 86_400 + TimeInterval(SecretExpiryPolicy.listingWindowDays) * 86_400 ) ) } diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift index 2e89ded6..9ce5ca96 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift @@ -5,7 +5,10 @@ import Foundation import DVCore public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiryNotificationsUseCase { - private static let daysBeforeExpiry = [7, 3] + private static let daysBeforeExpiry = [ + SecretExpiryPolicy.upcomingWindowDays, + SecretExpiryPolicy.criticalWindowDays, + ] private let repository: any SecretRepository private let notificationService: any SecurityNotificationService diff --git a/Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift b/Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift new file mode 100644 index 00000000..94f4b720 --- /dev/null +++ b/Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift @@ -0,0 +1,15 @@ +// Copyright © 2026 Devault. All rights reserved + +import Testing + +@testable import DVDomain + +@Suite("SecretExpiryPolicy") +struct SecretExpiryPolicyTests { + + @Test("단계별 기간은 critical < upcoming < listing 순으로 넓어진다") + func windowsAreOrdered() { + #expect(SecretExpiryPolicy.criticalWindowDays < SecretExpiryPolicy.upcomingWindowDays) + #expect(SecretExpiryPolicy.upcomingWindowDays < SecretExpiryPolicy.listingWindowDays) + } +} diff --git a/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift b/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift index bf8069c6..337b173e 100644 --- a/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift +++ b/Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift @@ -35,9 +35,9 @@ struct SecretQueryTests { #expect(a != b) } - @Test("noticeWindowDays는 7일이다") + @Test("noticeWindowDays는 배지의 upcoming window와 같은 7일이다") func noticeWindowDaysIsSevenDays() { - // upcomingWindow와의 일치 여부는 DVPresentation쪽 SecretExpiryStatusTests가 검증한다. + #expect(SecretQuery.Collection.noticeWindowDays == SecretExpiryPolicy.upcomingWindowDays) #expect(SecretQuery.Collection.noticeWindowDays == 7) } @@ -53,14 +53,14 @@ struct SecretQueryTests { #expect(windowEnd == expected) } - @Test("expiringWindow는 기준일을 expiringSoonWindowDays만큼 민 expired 컬렉션을 만든다") + @Test("expiringWindow는 기준일을 listingWindowDays만큼 민 expired 컬렉션을 만든다") func expiringWindowShiftsReferenceDate() { let today = Date(timeIntervalSince1970: 0) let collection = SecretQuery.Collection.expiringWindow(from: today) let expected = today.addingTimeInterval( - TimeInterval(SecretQuery.Collection.expiringSoonWindowDays) * 86_400 + TimeInterval(SecretExpiryPolicy.listingWindowDays) * 86_400 ) #expect(collection == .expired(referenceDate: expected)) } @@ -68,7 +68,7 @@ struct SecretQueryTests { @Test("expiringWindow는 이미 만료된 것과 window 이내 예정을 함께 담는다") func expiringWindowCoversPastAndUpcoming() { let today = Date(timeIntervalSince1970: 0) - let windowDays = TimeInterval(SecretQuery.Collection.expiringSoonWindowDays) + let windowDays = TimeInterval(SecretExpiryPolicy.listingWindowDays) guard case let .expired(windowEnd) = SecretQuery.Collection.expiringWindow(from: today) else { Issue.record("collection이 .expired가 아님") diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index b1b0dc82..65d9d208 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -242,7 +242,7 @@ extension SecretListView { // MARK: - ExpiryBucket -/// Expired 탭의 섹션 구분. 경계는 오늘 기준 7일/30일로 고정. +/// Expired 탭의 섹션 구분. 경계는 `SecretExpiryPolicy`의 upcoming/listing window를 그대로 쓴다. private enum ExpiryBucket: CaseIterable, Identifiable { case expired @@ -261,9 +261,13 @@ private enum ExpiryBucket: CaseIterable, Identifiable { func contains(_ expiresAt: Date?, referenceDate: Date) -> Bool { guard let expiresAt else { return false } - // Notice 탭과 같은 "7일" 기준을 쓴다. - let sevenDaysOut = SecretQuery.Collection.noticeWindowEnd(from: referenceDate) - let thirtyDaysOut = referenceDate.addingTimeInterval(30 * 86_400) + // Notice 탭(`noticeWindowDays`)과 같은 값에서 파생된다. + let sevenDaysOut = referenceDate.addingTimeInterval( + TimeInterval(SecretExpiryPolicy.upcomingWindowDays) * 86_400 + ) + let thirtyDaysOut = referenceDate.addingTimeInterval( + TimeInterval(SecretExpiryPolicy.listingWindowDays) * 86_400 + ) switch self { case .expired: diff --git a/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift b/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift index 95f0393d..3c1dca68 100644 --- a/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift +++ b/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift @@ -25,11 +25,12 @@ enum SecretExpiryStatus: Equatable { private static let secondsPerDay: TimeInterval = 86_400 /// 남은 기간이 이 값 이하(이미 지나 음수인 경우 포함)면 ``critical``. - static let criticalWindow: TimeInterval = 3 * secondsPerDay + /// 값 자체는 `SecretExpiryPolicy`가 소유한다 — 알림 스케줄·Expired 섹션 분류와 같은 기준을 쓴다. + static let criticalWindow: TimeInterval = TimeInterval(SecretExpiryPolicy.criticalWindowDays) * secondsPerDay /// 남은 기간이 ``criticalWindow`` 초과이면서 이 값 이하면 ``upcoming``. - /// Notice 탭과 같은 "7일" 기준을 쓰기 위해 `noticeWindowDays`에서 파생시킨다. - static let upcomingWindow = TimeInterval(SecretQuery.Collection.noticeWindowDays) * secondsPerDay + /// Notice 탭(`SecretQuery.Collection.noticeWindowDays`)도 같은 값을 파생시켜 쓴다. + static let upcomingWindow: TimeInterval = TimeInterval(SecretExpiryPolicy.upcomingWindowDays) * secondsPerDay /// 만료일로부터 상태를 산출한다. 만료일이 없거나 ``upcomingWindow``보다 멀면 `nil` — 아무 표시도 하지 않는다. /// diff --git a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift index 38eb5e5e..1d50d474 100644 --- a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift @@ -144,7 +144,7 @@ struct SecretListFeatureTests { return } let expectedWindowEnd = today.addingTimeInterval( - TimeInterval(SecretQuery.Collection.expiringSoonWindowDays) * 86_400 + TimeInterval(SecretExpiryPolicy.listingWindowDays) * 86_400 ) #expect(windowEnd == expectedWindowEnd) #expect(query.sort == SecretQuery.Sort(key: .expiry, direction: .ascending)) From f5905027ad162359e006d65d7c82f7d8bcdfd002 Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sun, 16 Aug 2026 11:43:45 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[#85]=20feat:=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=EC=97=90=20tooltip=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=20=EB=A7=8C=EB=A3=8C=EB=90=9C=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=EC=9D=80=20=EB=B0=B0=EC=A7=80=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록 행의 만료 배지(critical/upcoming)에 hover 시 "Expires within N days" tooltip이 뜨도록 DVVaultContainer에 trailingIconTooltip을 추가했다. 문구는 SecretExpiryPolicy 값을 그대로 참조해 만든다. 기존엔 이미 만료된 시크릿도 critical 배지가 떴는데("조치가 같으므로 구분할 이유 없음"이 원래 정책이었다), Expired 탭이 "이미 지남"을 전담하므로 다른 탭에서까지 배지로 중복 표시할 필요가 없다는 쪽으로 정책을 바꿨다. SecretExpiryStatus.init이 이미 지난 만료일을 nil로 판정하도록 수정하고, 관련 테스트·프리뷰 주석을 갱신했다. Co-Authored-By: Claude Sonnet 5 --- .../Sources/Components/DVVaultContainer.swift | 12 ++++++++- .../DetailExpireDateFieldView.swift | 3 ++- .../Features/SecretList/SecretListView.swift | 11 +++++--- .../Sources/Support/SecretExpiryStatus.swift | 21 +++++++++++---- .../Features/SecretExpiryStatusTests.swift | 26 +++++++++++++------ 5 files changed, 54 insertions(+), 19 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift index 760dff37..b86f5dd6 100644 --- a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift @@ -15,6 +15,8 @@ public struct DVVaultContainer: View { public let typeIcon: Image? /// 우측 만료 강조 아이콘. 어떤 단계로 볼지는 호출부의 만료 정책이 결정한다. public let trailingIcon: DVExpiryEmphasis? + /// `trailingIcon`에 hover 시 뜨는 설명 문구. `trailingIcon`이 `nil`이면 무시된다. + public let trailingIconTooltip: String? public let isSelected: Bool // MARK: - Init @@ -25,6 +27,7 @@ public struct DVVaultContainer: View { service: String? = nil, typeIcon: Image? = nil, trailingIcon: DVExpiryEmphasis? = nil, + trailingIconTooltip: String? = nil, isSelected: Bool = false ) { self.name = name @@ -32,6 +35,7 @@ public struct DVVaultContainer: View { self.service = service self.typeIcon = typeIcon self.trailingIcon = trailingIcon + self.trailingIconTooltip = trailingIconTooltip self.isSelected = isSelected } @@ -139,9 +143,15 @@ extension DVVaultContainer { @ViewBuilder private var trailingIconView: some View { if let trailingIcon { - trailingIcon.icon + let icon = trailingIcon.icon .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(trailingIcon.colorToken)) .fixedSize() + + if let trailingIconTooltip { + icon.help(trailingIconTooltip) + } else { + icon + } } } } diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift index fcd43053..44123507 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift @@ -55,7 +55,8 @@ private func _secret(expiresAt: Date?) -> Secret { ) } -#Preview("만료 임박 4단계 · paired") { +/// 이미 지난 만료일(-5일)은 강조 없음 — Expired 탭이 "이미 지남"을 전담하므로 여기선 중복 표시하지 않는다. +#Preview("만료 임박 3단계 · paired") { VStack(alignment: .leading, spacing: 16) { DetailExpireDateFieldView(secret: _secret(expiresAt: .now.addingTimeInterval(-5 * 86_400))) DetailExpireDateFieldView(secret: _secret(expiresAt: .now.addingTimeInterval(2 * 86_400))) diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index 65d9d208..3a0471ba 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -89,12 +89,15 @@ extension SecretListView { } private func row(for secret: Secret) -> some View { - DVVaultContainer( + let expiryStatus = expiryStatus(for: secret) + + return DVVaultContainer( name: secret.name, date: SecretDateFormatter.string(from: secret.updatedAt), service: secret.service, typeIcon: secret.secretType.icon, - trailingIcon: trailingIcon(for: secret), + trailingIcon: expiryStatus?.emphasis, + trailingIconTooltip: expiryStatus?.tooltipText, isSelected: secret.id == store.selectedSecretID ) .tag(secret.id) @@ -108,8 +111,8 @@ extension SecretListView { /// All/Star/Expired/Deleted 어디서든 만료 상태를 알려준다. /// 임계값은 `SecretExpiryStatus`가 소유한다 — 조회 화면 Expire Date 필드와 같은 정책을 써야 한다. - private func trailingIcon(for secret: Secret) -> DVExpiryEmphasis? { - SecretExpiryStatus(expiresAt: secret.expiresAt)?.emphasis + private func expiryStatus(for secret: Secret) -> SecretExpiryStatus? { + SecretExpiryStatus(expiresAt: secret.expiresAt) } /// All/Star/Expired는 "프로젝트에 추가/삭제", Deleted는 "복구/영구 삭제"를 보여준다. diff --git a/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift b/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift index 3c1dca68..81664636 100644 --- a/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift +++ b/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift @@ -11,10 +11,10 @@ import DVDomain /// 모순이 생긴다. 판정은 이 타입만 하고, 표현(아이콘·색)은 ``DVExpiryEmphasis``가 갖는다. enum SecretExpiryStatus: Equatable { - /// 이미 만료됐거나 ``criticalWindow`` 이내에 만료된다. + /// ``criticalWindow`` 이내에 만료된다 — 즉시 조치가 필요한 단계. /// - /// 이미 지난 경우를 별도 단계로 두지 않는 것은 의도된 정책이다 — - /// 사용자가 취해야 할 조치(갱신)가 같으므로 구분해서 보여줄 이유가 없다. + /// 이미 지난 시크릿은 이 케이스에 포함되지 않는다. Expired 탭 자체가 "이미 지남"을 + /// 전담해 보여주므로, 다른 탭에서까지 배지로 다시 강조하면 중복이다. case critical /// ``upcomingWindow`` 이내에 만료된다 — 아직 조치할 시간이 있는 예고 단계. @@ -32,13 +32,14 @@ enum SecretExpiryStatus: Equatable { /// Notice 탭(`SecretQuery.Collection.noticeWindowDays`)도 같은 값을 파생시켜 쓴다. static let upcomingWindow: TimeInterval = TimeInterval(SecretExpiryPolicy.upcomingWindowDays) * secondsPerDay - /// 만료일로부터 상태를 산출한다. 만료일이 없거나 ``upcomingWindow``보다 멀면 `nil` — 아무 표시도 하지 않는다. + /// 만료일로부터 상태를 산출한다. 이미 지났거나, 만료일이 없거나, ``upcomingWindow``보다 + /// 멀면 `nil` — 아무 표시도 하지 않는다. /// /// - Parameters: /// - expiresAt: 시크릿의 만료일. `nil`이면 만료 개념이 없는 시크릿이다. /// - now: 판정 기준 시각. 테스트가 고정 시각을 주입한다. init?(expiresAt: Date?, now: Date = .now) { - guard let expiresAt else { return nil } + guard let expiresAt, expiresAt > now else { return nil } if expiresAt <= now.addingTimeInterval(Self.criticalWindow) { self = .critical @@ -56,4 +57,14 @@ enum SecretExpiryStatus: Equatable { case .upcoming: return .warning } } + + /// 배지에 hover 시 뜨는 설명 문구. 아이콘·색만으로는 "며칠 남았는지"가 전달되지 않는다. + var tooltipText: String { + switch self { + case .critical: + return String.module("Expires within \(SecretExpiryPolicy.criticalWindowDays) days") + case .upcoming: + return String.module("Expires within \(SecretExpiryPolicy.upcomingWindowDays) days") + } + } } diff --git a/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift b/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift index 58058e5a..978c51bc 100644 --- a/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift +++ b/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift @@ -28,19 +28,21 @@ struct SecretExpiryStatusTests { #expect(SecretExpiryStatus(expiresAt: nil, now: Self.now) == nil) } - // MARK: - critical (이미 만료 + 3일 이내) + // MARK: - 이미 만료됨 (표시 없음) - @Test("이미 만료된 경우 critical — 3일 이내와 구분하지 않는다") - func alreadyExpiredIsCritical() { - #expect(Self.status(daysFromNow: -30) == .critical) - #expect(Self.status(daysFromNow: -1) == .critical) + @Test("이미 만료된 경우 nil — Expired 탭이 전담하므로 배지로 중복 표시하지 않는다") + func alreadyExpiredIsNil() { + #expect(Self.status(daysFromNow: -30) == nil) + #expect(Self.status(daysFromNow: -1) == nil) } - @Test("정확히 지금 만료되는 경우 critical") - func expiringExactlyNowIsCritical() { - #expect(Self.status(daysFromNow: 0) == .critical) + @Test("정확히 지금 만료되는 경우 nil — 이미 지난 것으로 취급한다") + func expiringExactlyNowIsNil() { + #expect(Self.status(daysFromNow: 0) == nil) } + // MARK: - critical (아직 안 지났고 3일 이내) + @Test("3일 이내는 critical") func withinThreeDaysIsCritical() { #expect(Self.status(daysFromNow: 1) == .critical) @@ -103,4 +105,12 @@ struct SecretExpiryStatusTests { == TimeInterval(SecretQuery.Collection.noticeWindowDays) * 86_400 ) } + + // MARK: - tooltip 문구 + + @Test("critical은 3일, upcoming은 7일 문구를 갖는다") + func tooltipTextReflectsWindowDays() { + #expect(SecretExpiryStatus.critical.tooltipText.contains("3")) + #expect(SecretExpiryStatus.upcoming.tooltipText.contains("7")) + } } From 3081d39851d5f0b3fb6aad03fd153de8f4bedc79 Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sun, 16 Aug 2026 14:07:21 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[#85]=20fix:=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20hover=20=ED=88=B4=ED=8C=81=EC=9D=B4=20?= =?UTF-8?q?=EC=95=88=20=EB=9C=A8=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI .help(_:)를 List 행 안의 배지에 붙였더니 완전히 무반응이었다. AppKit NSView.toolTip으로 직접 우회해도 마찬가지였다 — mouseEntered/ mouseExited는 정상적으로 도달하는데(직접 로그로 검증) 시스템 tooltip 렌더링 자체가 이 SwiftUI 호스팅 컨텍스트에서 안 됐다. hover 상태를 NSTrackingArea로 직접 감지해 별도 borderless NSPanel에 말풍선을 그리는 방식으로 바꿨다. 화면 절대 좌표에 띄우므로 List가 자기 폭 밖 콘텐츠를 잘라내는 문제도 함께 해결된다(처음엔 SwiftUI overlay로 그렸다가 텍스트가 잘려서 이 방식으로 갔다). 디자인은 macOS 시스템 tooltip과 맞춰 밝은 회색 배경 + 어두운 텍스트, 위치는 배지 아래쪽으로 뒀다. --- .../Sources/DVVaultContainerPreviewView.swift | 1 + .../Sources/Components/DVVaultContainer.swift | 12 +- .../Foundations/Tooltip/HoverTooltip.swift | 134 ++++++++++++++++++ 3 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift diff --git a/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift index 602beba0..de08c504 100644 --- a/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift +++ b/Projects/DVDesign/SampleApp/Sources/DVVaultContainerPreviewView.swift @@ -70,6 +70,7 @@ extension DVVaultContainerPreviewView { service: vaults[index].3, typeIcon: vaults[index].4 ? placeholderTypeIcon : nil, trailingIcon: vaults[index].2, + trailingIconTooltip: vaults[index].2 != nil ? "Expires soon" : nil, isSelected: selectedIndex == index ) .tag(index) diff --git a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift index b86f5dd6..33f86b00 100644 --- a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift @@ -143,15 +143,13 @@ extension DVVaultContainer { @ViewBuilder private var trailingIconView: some View { if let trailingIcon { - let icon = trailingIcon.icon + trailingIcon.icon .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(trailingIcon.colorToken)) .fixedSize() - - if let trailingIconTooltip { - icon.help(trailingIconTooltip) - } else { - icon - } + // `.help(_:)`·`NSView.toolTip` 둘 다 List 행 안에서 확인해보니 안 뜬다 + // (mouseEntered/Exited는 정상 도달하지만 시스템 tooltip 렌더링만 안 됨) — + // hover 상태를 직접 받아 커스텀 말풍선을 그리는 방식으로 우회한다. + .hoverTooltip(trailingIconTooltip) } } } diff --git a/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift b/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift new file mode 100644 index 00000000..c990e3f5 --- /dev/null +++ b/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift @@ -0,0 +1,134 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// hover 시 커스텀 말풍선을 띄우는 툴팁 modifier. +/// +/// SwiftUI `.help(_:)`와 AppKit `NSView.toolTip` 둘 다 이 프로젝트의 `List` 행 컨텍스트에서 +/// 확인해보니 동작하지 않았다 — 마우스 트래킹 이벤트는 정상 도달하는데 시스템 tooltip 렌더링만 안 됐다. +/// +/// SwiftUI `.overlay`로 직접 말풍선을 그리는 것도 시도했지만, `List`가 자기 폭 밖으로 나가는 +/// 콘텐츠를 잘라내서 긴 텍스트가 잘렸다. 그래서 별도의 borderless `NSWindow`를 화면 절대 좌표에 +/// 띄우는 방식으로 간다 — macOS 시스템 tooltip이 실제로 동작하는 방식과 같다. `List`/부모 뷰의 +/// 경계와 무관하게 항상 온전히 그려진다. +extension View { + /// `text`가 `nil`이면 아무것도 붙이지 않는다. + public func hoverTooltip(_ text: String?) -> some View { + overlay { + if let text { + HoverTooltipHost(text: text) + } + } + } +} + +private struct HoverTooltipHost: NSViewRepresentable { + let text: String + + func makeNSView(context: Context) -> TrackingView { + let view = TrackingView() + view.text = text + return view + } + + func updateNSView(_ nsView: TrackingView, context: Context) { + nsView.text = text + } +} + +/// hover를 감지해 별도 `NSPanel`에 말풍선을 띄우는 뷰. `List`의 clipping과 무관하게 +/// 화면 절대 좌표에 그려지므로 항상 온전한 크기로 보인다. +private final class TrackingView: NSView { + var text: String? + + private var trackingArea: NSTrackingArea? + private var tooltipPanel: NSPanel? + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + removeTrackingArea(trackingArea) + } + let newTrackingArea = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(newTrackingArea) + trackingArea = newTrackingArea + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + showTooltip() + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + hideTooltip() + } + + override func removeFromSuperview() { + hideTooltip() + super.removeFromSuperview() + } + + private func showTooltip() { + guard let text, let window, !text.isEmpty else { return } + + let hosting = NSHostingView(rootView: TooltipBubble(text: text)) + let size = hosting.fittingSize + hosting.frame = CGRect(origin: .zero, size: size) + + let panel = NSPanel( + contentRect: CGRect(origin: .zero, size: size), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .popUpMenu + panel.ignoresMouseEvents = true + panel.contentView = hosting + + let boundsInWindow = convert(bounds, to: nil) + let boundsOnScreen = window.convertToScreen(boundsInWindow) + let origin = CGPoint( + x: boundsOnScreen.midX - size.width / 2, + y: boundsOnScreen.minY - size.height - 6 + ) + panel.setFrameOrigin(origin) + window.addChildWindow(panel, ordered: .above) + tooltipPanel = panel + } + + private func hideTooltip() { + if let tooltipPanel { + tooltipPanel.parent?.removeChildWindow(tooltipPanel) + tooltipPanel.orderOut(nil) + } + tooltipPanel = nil + } +} + +/// hover 말풍선. macOS 시스템 tooltip과 비슷한 외관(밝은 회색 배경 + 어두운 텍스트)으로 맞춘다. +private struct TooltipBubble: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 12)) + .foregroundStyle(Color(NSColor.labelColor)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(NSColor.windowBackgroundColor), in: RoundedRectangle(cornerRadius: 4)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + .fixedSize() + } +} From 289a04b1e2960bf84141455e391eadbb5905486a Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sun, 16 Aug 2026 14:18:21 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[#85]=20fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EA=B0=95=EC=A1=B0=20=EB=B3=B5=EC=9B=90?= =?UTF-8?q?,=20identifier=20=EA=B2=B0=ED=95=A9=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=93=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SecretExpiryStatus에 .expired 복원. "이미 지난 건 배지 제외"는 판정 타입이 아니라 소비처(SecretListView.row)가 결정하게 옮겨서, 조회 화면(DetailExpireDateFieldView)의 강조가 다시 뜬다. - ExpiryBucket.title의 7/30일 리터럴을 SecretExpiryPolicy 보간으로. 로컬라이즈도 함께 처리(.module). - 알림 스케줄러가 SecretExpiryPolicy 대신 독립 상수 notificationLeadDays를 쓰도록 분리. cancel이 그 값으로 identifier를 재구성해서, 참조하면 값 변경 시 이전 예약을 못 지운다. - stale 주석 정리 (SecretExpiryStatus, SecretListView, SecretExpiryPolicy). - SecretExpiryPolicyTests에 실제 값(3/7/30) 고정 테스트 추가 — 순서 검증만으로는 값이 바뀌어도 항상 통과한다. --- .../Sources/Components/DVVaultContainer.swift | 4 +-- .../Foundations/Tooltip/HoverTooltip.swift | 13 +++---- .../Sources/Entity/SecretExpiryPolicy.swift | 13 ++++--- ...SecretExpiryNotificationsUseCaseImpl.swift | 13 ++++--- .../Core/Entity/SecretExpiryPolicyTests.swift | 16 +++++++++ .../DetailExpireDateFieldView.swift | 4 +-- .../Features/SecretList/SecretListView.swift | 18 ++++++---- .../Sources/Support/SecretExpiryStatus.swift | 34 ++++++++----------- .../Features/SecretExpiryStatusTests.swift | 22 ++++++------ 9 files changed, 73 insertions(+), 64 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift index 33f86b00..cc3627e7 100644 --- a/Projects/DVDesign/Sources/Components/DVVaultContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVVaultContainer.swift @@ -146,9 +146,7 @@ extension DVVaultContainer { trailingIcon.icon .foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(trailingIcon.colorToken)) .fixedSize() - // `.help(_:)`·`NSView.toolTip` 둘 다 List 행 안에서 확인해보니 안 뜬다 - // (mouseEntered/Exited는 정상 도달하지만 시스템 tooltip 렌더링만 안 됨) — - // hover 상태를 직접 받아 커스텀 말풍선을 그리는 방식으로 우회한다. + // `.help(_:)`가 List 행 안에서 안 떠서 커스텀 말풍선으로 우회한다. .hoverTooltip(trailingIconTooltip) } } diff --git a/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift b/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift index c990e3f5..939a791a 100644 --- a/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift +++ b/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift @@ -4,13 +4,9 @@ import SwiftUI /// hover 시 커스텀 말풍선을 띄우는 툴팁 modifier. /// -/// SwiftUI `.help(_:)`와 AppKit `NSView.toolTip` 둘 다 이 프로젝트의 `List` 행 컨텍스트에서 -/// 확인해보니 동작하지 않았다 — 마우스 트래킹 이벤트는 정상 도달하는데 시스템 tooltip 렌더링만 안 됐다. -/// -/// SwiftUI `.overlay`로 직접 말풍선을 그리는 것도 시도했지만, `List`가 자기 폭 밖으로 나가는 -/// 콘텐츠를 잘라내서 긴 텍스트가 잘렸다. 그래서 별도의 borderless `NSWindow`를 화면 절대 좌표에 -/// 띄우는 방식으로 간다 — macOS 시스템 tooltip이 실제로 동작하는 방식과 같다. `List`/부모 뷰의 -/// 경계와 무관하게 항상 온전히 그려진다. +/// `.help(_:)`와 `NSView.toolTip` 둘 다 `List` 행 안에서 안 떴다(마우스 이벤트는 도달하는데 +/// 시스템 tooltip 렌더링만 안 됨). `.overlay`로 직접 그리면 `List`가 폭 밖 콘텐츠를 잘라서, +/// 화면 절대 좌표의 별도 `NSWindow`로 띄운다 — 실제 시스템 tooltip과 같은 방식. extension View { /// `text`가 `nil`이면 아무것도 붙이지 않는다. public func hoverTooltip(_ text: String?) -> some View { @@ -36,8 +32,7 @@ private struct HoverTooltipHost: NSViewRepresentable { } } -/// hover를 감지해 별도 `NSPanel`에 말풍선을 띄우는 뷰. `List`의 clipping과 무관하게 -/// 화면 절대 좌표에 그려지므로 항상 온전한 크기로 보인다. +/// hover를 감지해 별도 `NSPanel`에 말풍선을 띄우는 뷰. private final class TrackingView: NSView { var text: String? diff --git a/Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift b/Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift index e7a7b8f7..9458e49f 100644 --- a/Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift +++ b/Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift @@ -4,18 +4,17 @@ import Foundation /// "만료가 임박했다"를 판단하는 기간 상수의 단일 소스. /// -/// 같은 개념(3일 / 7일 / 30일)이 배지 표시(`SecretExpiryStatus`), 만료 알림 스케줄 -/// (`ScheduleSecretExpiryNotificationsUseCaseImpl`), Expired 탭 섹션 분류(`ExpiryBucket`), -/// Expired 탭 쿼리 범위(`SecretQuery.Collection.expiringWindow`)에 각각 필요하다. -/// 값을 여기 하나로 모아두지 않으면 한 곳만 바뀌었을 때 나머지가 조용히 어긋난다 -/// (예: 카드에 찍힌 개수와 목록에 뜨는 개수가 달라짐). +/// 같은 개념(3일 / 7일 / 30일)이 배지 표시(`SecretExpiryStatus`), Expired 탭 섹션 분류 +/// (`ExpiryBucket`), Expired 탭 쿼리 범위(`SecretQuery.Collection.expiringWindow`)에 +/// 각각 필요하다. 값을 여기 하나로 모아두지 않으면 한 곳만 바뀌었을 때 나머지가 조용히 +/// 어긋난다. 만료 알림(`ScheduleSecretExpiryNotificationsUseCaseImpl`)은 값이 같을 뿐 +/// 의도적으로 여기 묶여있지 않다 — identifier 재구성 때문에 독립 상수를 쓴다. /// /// 판정 로직(무엇을 배지로 보여줄지, 무엇을 섹션으로 나눌지)은 목적마다 달라 여기서 /// 통합하지 않는다. 여기서는 값만 소유한다. public enum SecretExpiryPolicy { - /// 즉시 조치가 필요한 단계로 볼 기간(일). 이미 지난 경우도 이 안에 포함해 판정하는 것은 - /// 소비자(`SecretExpiryStatus`)의 정책이다. + /// 즉시 조치가 필요한 단계로 볼 기간(일). public static let criticalWindowDays = 3 /// 아직 조치할 시간이 있는 예고 단계로 볼 기간(일). diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift index 9ce5ca96..25e94534 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift @@ -5,10 +5,9 @@ import Foundation import DVCore public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiryNotificationsUseCase { - private static let daysBeforeExpiry = [ - SecretExpiryPolicy.upcomingWindowDays, - SecretExpiryPolicy.criticalWindowDays, - ] + /// `SecretExpiryPolicy`와 값이 같지만 우연일 뿐 의도적 결합 아님 — `cancel`이 이 값으로 + /// identifier를 재구성해서, 참조하면 값 변경 시 이전 예약을 못 지운다. + private static let notificationLeadDays = [7, 3] private let repository: any SecretRepository private let notificationService: any SecurityNotificationService @@ -47,7 +46,7 @@ public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiry // expiresAt이 바뀌었을 수 있어 이전 마크가 stale하게 남지 않도록 먼저 전부 취소한다. await cancel(secretID: secret.id) - for daysBefore in Self.daysBeforeExpiry { + for daysBefore in Self.notificationLeadDays { guard let dayMark = Calendar.current.date(byAdding: .day, value: -daysBefore, to: expiresAt) else { continue } @@ -74,8 +73,8 @@ public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiry } public func cancel(secretID: UUID) async { - // daysBeforeExpiry에 대응하는 identifier를 전부 취소 — 이미 소비된 것도 무시되니 존재 확인 안함 - let identifiers = Self.daysBeforeExpiry.map { Self.notificationID(secretID: secretID, daysBefore: $0) } + // notificationLeadDays에 대응하는 identifier를 전부 취소 — 이미 소비된 것도 무시되니 존재 확인 안함 + let identifiers = Self.notificationLeadDays.map { Self.notificationID(secretID: secretID, daysBefore: $0) } await notificationService.cancel(identifiers: identifiers) } diff --git a/Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift b/Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift index 94f4b720..68cffb9c 100644 --- a/Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift +++ b/Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift @@ -7,6 +7,22 @@ import Testing @Suite("SecretExpiryPolicy") struct SecretExpiryPolicyTests { + /// 순서 검증만으로는 값이 바뀌어도 항상 통과해 회귀를 못 잡는다. + @Test("criticalWindowDays는 3일이다") + func criticalWindowDaysIsThree() { + #expect(SecretExpiryPolicy.criticalWindowDays == 3) + } + + @Test("upcomingWindowDays는 7일이다") + func upcomingWindowDaysIsSeven() { + #expect(SecretExpiryPolicy.upcomingWindowDays == 7) + } + + @Test("listingWindowDays는 30일이다") + func listingWindowDaysIsThirty() { + #expect(SecretExpiryPolicy.listingWindowDays == 30) + } + @Test("단계별 기간은 critical < upcoming < listing 순으로 넓어진다") func windowsAreOrdered() { #expect(SecretExpiryPolicy.criticalWindowDays < SecretExpiryPolicy.upcomingWindowDays) diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift index 44123507..451c3cb4 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailExpireDateFieldView.swift @@ -55,8 +55,8 @@ private func _secret(expiresAt: Date?) -> Secret { ) } -/// 이미 지난 만료일(-5일)은 강조 없음 — Expired 탭이 "이미 지남"을 전담하므로 여기선 중복 표시하지 않는다. -#Preview("만료 임박 3단계 · paired") { +/// 이미 지난 만료일(-5일)도 강조된다 — 목록 배지와 달리 필터링하지 않는다. +#Preview("만료 임박 4단계 · paired") { VStack(alignment: .leading, spacing: 16) { DetailExpireDateFieldView(secret: _secret(expiresAt: .now.addingTimeInterval(-5 * 86_400))) DetailExpireDateFieldView(secret: _secret(expiresAt: .now.addingTimeInterval(2 * 86_400))) diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index 3a0471ba..8b25d636 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -89,15 +89,16 @@ extension SecretListView { } private func row(for secret: Secret) -> some View { - let expiryStatus = expiryStatus(for: secret) + // 이미 지난 건 배지로 안 보여준다 — Expired 탭이 전담한다. + let badgeStatus = expiryStatus(for: secret).flatMap { $0 == .expired ? nil : $0 } return DVVaultContainer( name: secret.name, date: SecretDateFormatter.string(from: secret.updatedAt), service: secret.service, typeIcon: secret.secretType.icon, - trailingIcon: expiryStatus?.emphasis, - trailingIconTooltip: expiryStatus?.tooltipText, + trailingIcon: badgeStatus?.emphasis, + trailingIconTooltip: badgeStatus?.tooltipText, isSelected: secret.id == store.selectedSecretID ) .tag(secret.id) @@ -110,7 +111,7 @@ extension SecretListView { } /// All/Star/Expired/Deleted 어디서든 만료 상태를 알려준다. - /// 임계값은 `SecretExpiryStatus`가 소유한다 — 조회 화면 Expire Date 필드와 같은 정책을 써야 한다. + /// 임계값은 `SecretExpiryPolicy`가 소유한다 — 조회 화면 Expire Date 필드와 같은 정책을 써야 한다. private func expiryStatus(for secret: Secret) -> SecretExpiryStatus? { SecretExpiryStatus(expiresAt: secret.expiresAt) } @@ -256,9 +257,12 @@ private enum ExpiryBucket: CaseIterable, Identifiable { var title: String { switch self { - case .expired: return "Expired" - case .within7Days: return "Expires in 7 days" - case .within30Days: return "Expires in 30 days" + case .expired: + return String.module("Expired") + case .within7Days: + return String.module("Expires in \(SecretExpiryPolicy.upcomingWindowDays) days") + case .within30Days: + return String.module("Expires in \(SecretExpiryPolicy.listingWindowDays) days") } } diff --git a/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift b/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift index 81664636..05b3b5c8 100644 --- a/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift +++ b/Projects/DVPresentation/Sources/Support/SecretExpiryStatus.swift @@ -5,16 +5,15 @@ import Foundation import DVDesign import DVDomain -/// 만료 임박 표기 정책의 단일 정의부 — 목록(`SecretListView`)과 조회(`DetailExpireDateFieldView`)가 함께 쓴다. -/// -/// 두 화면이 임계값을 각자 들고 있으면 같은 시크릿이 목록에선 표시가 없고 조회에선 강조되는 -/// 모순이 생긴다. 판정은 이 타입만 하고, 표현(아이콘·색)은 ``DVExpiryEmphasis``가 갖는다. +/// 만료 임박 표기 정책의 단일 정의부 — 목록과 조회 화면이 함께 쓴다. +/// "이미 지난 것을 뺄지"는 소비처(`SecretListView.row(for:)`) 몫이다 — 여기서 걸러내면 +/// 조회 화면도 강제로 끌려간다. enum SecretExpiryStatus: Equatable { - /// ``criticalWindow`` 이내에 만료된다 — 즉시 조치가 필요한 단계. - /// - /// 이미 지난 시크릿은 이 케이스에 포함되지 않는다. Expired 탭 자체가 "이미 지남"을 - /// 전담해 보여주므로, 다른 탭에서까지 배지로 다시 강조하면 중복이다. + /// 이미 만료됐다. + case expired + + /// 아직 안 지났고 ``criticalWindow`` 이내에 만료된다 — 즉시 조치가 필요한 단계. case critical /// ``upcomingWindow`` 이내에 만료된다 — 아직 조치할 시간이 있는 예고 단계. @@ -24,24 +23,19 @@ enum SecretExpiryStatus: Equatable { /// 목록의 `ExpiryBucket`과 같은 기준을 쓰기 위한 것이다. private static let secondsPerDay: TimeInterval = 86_400 - /// 남은 기간이 이 값 이하(이미 지나 음수인 경우 포함)면 ``critical``. - /// 값 자체는 `SecretExpiryPolicy`가 소유한다 — 알림 스케줄·Expired 섹션 분류와 같은 기준을 쓴다. static let criticalWindow: TimeInterval = TimeInterval(SecretExpiryPolicy.criticalWindowDays) * secondsPerDay /// 남은 기간이 ``criticalWindow`` 초과이면서 이 값 이하면 ``upcoming``. /// Notice 탭(`SecretQuery.Collection.noticeWindowDays`)도 같은 값을 파생시켜 쓴다. static let upcomingWindow: TimeInterval = TimeInterval(SecretExpiryPolicy.upcomingWindowDays) * secondsPerDay - /// 만료일로부터 상태를 산출한다. 이미 지났거나, 만료일이 없거나, ``upcomingWindow``보다 - /// 멀면 `nil` — 아무 표시도 하지 않는다. - /// - /// - Parameters: - /// - expiresAt: 시크릿의 만료일. `nil`이면 만료 개념이 없는 시크릿이다. - /// - now: 판정 기준 시각. 테스트가 고정 시각을 주입한다. + /// 만료일이 없거나 ``upcomingWindow``보다 멀면 `nil`. init?(expiresAt: Date?, now: Date = .now) { - guard let expiresAt, expiresAt > now else { return nil } + guard let expiresAt else { return nil } - if expiresAt <= now.addingTimeInterval(Self.criticalWindow) { + if expiresAt <= now { + self = .expired + } else if expiresAt <= now.addingTimeInterval(Self.criticalWindow) { self = .critical } else if expiresAt <= now.addingTimeInterval(Self.upcomingWindow) { self = .upcoming @@ -53,7 +47,7 @@ enum SecretExpiryStatus: Equatable { /// 단계별 표현. 목록 행과 조회 필드가 이 하나를 통해 같은 아이콘·색을 얻는다. var emphasis: DVExpiryEmphasis { switch self { - case .critical: return .danger + case .expired, .critical: return .danger case .upcoming: return .warning } } @@ -61,6 +55,8 @@ enum SecretExpiryStatus: Equatable { /// 배지에 hover 시 뜨는 설명 문구. 아이콘·색만으로는 "며칠 남았는지"가 전달되지 않는다. var tooltipText: String { switch self { + case .expired: + return String.module("Expired") case .critical: return String.module("Expires within \(SecretExpiryPolicy.criticalWindowDays) days") case .upcoming: diff --git a/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift b/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift index 978c51bc..27edbdf2 100644 --- a/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift +++ b/Projects/DVPresentation/Tests/Features/SecretExpiryStatusTests.swift @@ -28,17 +28,17 @@ struct SecretExpiryStatusTests { #expect(SecretExpiryStatus(expiresAt: nil, now: Self.now) == nil) } - // MARK: - 이미 만료됨 (표시 없음) + // MARK: - expired (이미 지남) - @Test("이미 만료된 경우 nil — Expired 탭이 전담하므로 배지로 중복 표시하지 않는다") - func alreadyExpiredIsNil() { - #expect(Self.status(daysFromNow: -30) == nil) - #expect(Self.status(daysFromNow: -1) == nil) + @Test("이미 만료된 경우 expired") + func alreadyExpiredIsExpired() { + #expect(Self.status(daysFromNow: -30) == .expired) + #expect(Self.status(daysFromNow: -1) == .expired) } - @Test("정확히 지금 만료되는 경우 nil — 이미 지난 것으로 취급한다") - func expiringExactlyNowIsNil() { - #expect(Self.status(daysFromNow: 0) == nil) + @Test("정확히 지금 만료되는 경우 expired") + func expiringExactlyNowIsExpired() { + #expect(Self.status(daysFromNow: 0) == .expired) } // MARK: - critical (아직 안 지났고 3일 이내) @@ -83,8 +83,9 @@ struct SecretExpiryStatusTests { // MARK: - 표현 매핑 - @Test("critical은 danger, upcoming은 warning으로 강조된다") + @Test("expired·critical은 danger, upcoming은 warning으로 강조된다") func emphasisMapping() { + #expect(SecretExpiryStatus.expired.emphasis == DVExpiryEmphasis.danger) #expect(SecretExpiryStatus.critical.emphasis == DVExpiryEmphasis.danger) #expect(SecretExpiryStatus.upcoming.emphasis == DVExpiryEmphasis.warning) } @@ -108,8 +109,9 @@ struct SecretExpiryStatusTests { // MARK: - tooltip 문구 - @Test("critical은 3일, upcoming은 7일 문구를 갖는다") + @Test("expired는 고정 문구, critical은 3일, upcoming은 7일 문구를 갖는다") func tooltipTextReflectsWindowDays() { + #expect(SecretExpiryStatus.expired.tooltipText == "Expired") #expect(SecretExpiryStatus.critical.tooltipText.contains("3")) #expect(SecretExpiryStatus.upcoming.tooltipText.contains("7")) } From 3bdbc5577400f51ad53122abdc9d3def5e999701 Mon Sep 17 00:00:00 2001 From: YESEONG LEE Date: Sun, 16 Aug 2026 15:53:19 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[#85]=20fix:=20=ED=88=B4=ED=8C=81=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=EB=88=84=EC=88=98=20=EB=B0=8F=20=ED=9E=88?= =?UTF-8?q?=ED=8A=B8=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B0=84=EC=84=AD=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - showTooltip()이 기존 패널을 안 지우고 덮어써서, mouseExited 없이 mouseEntered가 다시 오면(트래킹 재등록 시) 이전 패널이 참조를 잃고 화면에 영구히 남을 수 있었다. 첫 줄에서 hideTooltip() 호출, deinit 에서도 정리, isReleasedWhenClosed = false 추가. - TrackingView가 hitTest를 안 막아서 배지 클릭이 List 행 선택/ 컨텍스트 메뉴로 못 갈 수 있었다. hover 전용이라 hitTest에서 뺐다. --- .../Sources/Foundations/Tooltip/HoverTooltip.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift b/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift index 939a791a..a3c7a1d5 100644 --- a/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift +++ b/Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift @@ -69,8 +69,17 @@ private final class TrackingView: NSView { super.removeFromSuperview() } + // hover 전용이라 히트테스트에서 빠진다 — 안 그러면 배지 클릭이 행 선택을 막을 수 있다. + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + deinit { + hideTooltip() + } + private func showTooltip() { guard let text, let window, !text.isEmpty else { return } + // mouseExited 없이 mouseEntered가 다시 올 수 있어(트래킹 재등록 시) 먼저 정리한다. + hideTooltip() let hosting = NSHostingView(rootView: TooltipBubble(text: text)) let size = hosting.fittingSize @@ -87,6 +96,7 @@ private final class TrackingView: NSView { panel.hasShadow = true panel.level = .popUpMenu panel.ignoresMouseEvents = true + panel.isReleasedWhenClosed = false panel.contentView = hosting let boundsInWindow = convert(bounds, to: nil) From fdbf735965c404a8db6de51d2add4cb725815fd0 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:25:39 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[#85]=20fix:=20develop=20=EC=BB=B4=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=ED=9A=8C=EA=B7=80=20=E2=80=94=20notice=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EC=B0=B8=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/SecretList/SecretListFeature.swift | 6 +++++- .../Tests/SecretList/SecretListFeatureTests.swift | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift index 8cb62dd1..6b62183e 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift @@ -45,7 +45,11 @@ public struct SecretListFeature { case .notice: // predicate가 이미 "지나지 않음 + window 이내"를 전부 검사하므로 window 변환이 필요 없다. // 임박한 것부터 보여주는 게 자연스러워 정렬은 고정한다(사용자가 바꿀 이유가 없는 화면). - return SecretQuery(collection: collection, searchText: normalizedSearchText, sort: .expiringSoon) + return SecretQuery( + collection: collection, + searchText: normalizedSearchText, + sort: SecretQuery.Sort(key: .expiry, direction: .ascending) + ) case .all, .liked, .deleted, .project: return SecretQuery(collection: collection, searchText: normalizedSearchText, sort: sort) } diff --git a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift index 1d50d474..ab6ea212 100644 --- a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift @@ -150,7 +150,7 @@ struct SecretListFeatureTests { #expect(query.sort == SecretQuery.Sort(key: .expiry, direction: .ascending)) } - @Test("notice collection의 query는 collection을 그대로 쓰고 expiringSoon 정렬을 강제한다") + @Test("notice collection의 query는 collection을 그대로 쓰고 만료 오름차순 정렬을 강제한다") func noticeQueryForcesExpiringSoonSort() { let today = Date(timeIntervalSince1970: 0) let state = SecretListFeature.State(collection: .notice(referenceDate: today)) @@ -159,7 +159,7 @@ struct SecretListFeatureTests { // predicate가 이미 window 전체를 검사하므로 .expired와 달리 collection 변환이 필요 없다. #expect(query.collection == .notice(referenceDate: today)) - #expect(query.sort == .expiringSoon) + #expect(query.sort == SecretQuery.Sort(key: .expiry, direction: .ascending)) } @Test("didSelectSecret은 selectedSecretID를 갱신하고 delegate로 알린다")