Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions Projects/DVDesign/Sources/Components/DVVaultContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,13 +27,15 @@ public struct DVVaultContainer: View {
service: String? = nil,
typeIcon: Image? = nil,
trailingIcon: DVExpiryEmphasis? = nil,
trailingIconTooltip: String? = nil,
isSelected: Bool = false
) {
self.name = name
self.date = date
self.service = service
self.typeIcon = typeIcon
self.trailingIcon = trailingIcon
self.trailingIconTooltip = trailingIconTooltip
self.isSelected = isSelected
}

Expand Down Expand Up @@ -142,6 +146,8 @@ extension DVVaultContainer {
trailingIcon.icon
.foregroundStyle(isSelected ? Color.dv(.white) : Color.dv(trailingIcon.colorToken))
.fixedSize()
// `.help(_:)`가 List 행 안에서 안 떠서 커스텀 말풍선으로 우회한다.
.hoverTooltip(trailingIconTooltip)
}
}
}
139 changes: 139 additions & 0 deletions Projects/DVDesign/Sources/Foundations/Tooltip/HoverTooltip.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright © 2026 Devault. All rights reserved

import SwiftUI

/// hover 시 커스텀 말풍선을 띄우는 툴팁 modifier.
///
/// `.help(_:)`와 `NSView.toolTip` 둘 다 `List` 행 안에서 안 떴다(마우스 이벤트는 도달하는데
/// 시스템 tooltip 렌더링만 안 됨). `.overlay`로 직접 그리면 `List`가 폭 밖 콘텐츠를 잘라서,
/// 화면 절대 좌표의 별도 `NSWindow`로 띄운다 — 실제 시스템 tooltip과 같은 방식.
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`에 말풍선을 띄우는 뷰.
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()
}

// 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
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.isReleasedWhenClosed = false
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()
}
}
25 changes: 25 additions & 0 deletions Projects/DVDomain/Sources/Entity/SecretExpiryPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright © 2026 Devault. All rights reserved

import Foundation

/// "만료가 임박했다"를 판단하는 기간 상수의 단일 소스.
///
/// 같은 개념(3일 / 7일 / 30일)이 배지 표시(`SecretExpiryStatus`), Expired 탭 섹션 분류
/// (`ExpiryBucket`), Expired 탭 쿼리 범위(`SecretQuery.Collection.expiringWindow`)에
/// 각각 필요하다. 값을 여기 하나로 모아두지 않으면 한 곳만 바뀌었을 때 나머지가 조용히
/// 어긋난다. 만료 알림(`ScheduleSecretExpiryNotificationsUseCaseImpl`)은 값이 같을 뿐
/// 의도적으로 여기 묶여있지 않다 — identifier 재구성 때문에 독립 상수를 쓴다.
///
/// 판정 로직(무엇을 배지로 보여줄지, 무엇을 섹션으로 나눌지)은 목적마다 달라 여기서
/// 통합하지 않는다. 여기서는 값만 소유한다.
public enum SecretExpiryPolicy {

/// 즉시 조치가 필요한 단계로 볼 기간(일).
public static let criticalWindowDays = 3

/// 아직 조치할 시간이 있는 예고 단계로 볼 기간(일).
public static let upcomingWindowDays = 7

/// Expired 탭에 "만료 예정"으로 함께 보여줄 범위(일).
public static let listingWindowDays = 30
}
11 changes: 4 additions & 7 deletions Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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만큼
/// 미래로 밀어서 두 범위를 함께 가져온다. 목록 조회와 사이드바 개수 집계가 **같은 함수**를
Expand All @@ -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
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import Foundation
import DVCore

public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiryNotificationsUseCase {
private static let daysBeforeExpiry = [7, 3]
/// `SecretExpiryPolicy`와 값이 같지만 우연일 뿐 의도적 결합 아님 — `cancel`이 이 값으로
/// identifier를 재구성해서, 참조하면 값 변경 시 이전 예약을 못 지운다.
private static let notificationLeadDays = [7, 3]

private let repository: any SecretRepository
private let notificationService: any SecurityNotificationService
Expand Down Expand Up @@ -44,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
}
Expand All @@ -71,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)
}

Expand Down
31 changes: 31 additions & 0 deletions Projects/DVDomain/Tests/Core/Entity/SecretExpiryPolicyTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright © 2026 Devault. All rights reserved

import Testing

@testable import DVDomain

@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)
#expect(SecretExpiryPolicy.upcomingWindowDays < SecretExpiryPolicy.listingWindowDays)
}
}
10 changes: 5 additions & 5 deletions Projects/DVDomain/Tests/Core/Repository/SecretQueryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -53,22 +53,22 @@ 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))
}

@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가 아님")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ private func _secret(expiresAt: Date?) -> Secret {
)
}

/// 이미 지난 만료일(-5일)도 강조된다 — 목록 배지와 달리 필터링하지 않는다.
#Preview("만료 임박 4단계 · paired") {
VStack(alignment: .leading, spacing: 16) {
DetailExpireDateFieldView(secret: _secret(expiresAt: .now.addingTimeInterval(-5 * 86_400)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading