diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..49c81cd
--- /dev/null
+++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,13 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "watchos",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/Contents.json b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift
new file mode 100644
index 0000000..13ff0e9
--- /dev/null
+++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/ContentView.swift
@@ -0,0 +1,213 @@
+//
+// ContentView.swift
+// Haru_DanimWatch Watch App
+//
+// 워치 앱 본체 (CLAUDE.md §9.1)
+// 꼬리표 목록 → 행동 목록 → 탭으로 실행 (시간형 토글 / 횟수형 +1)
+//
+
+import SwiftUI
+
+struct ContentView: View {
+ @State private var store = WatchStore.shared
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ #if DEBUG
+ if UserDefaults.standard.bool(forKey: "complicationPreview") {
+ ComplicationPreviewScreen()
+ } else {
+ mainContent
+ }
+ #else
+ mainContent
+ #endif
+ }
+ .navigationTitle("하루다님")
+ }
+ .onAppear { store.activate() }
+ }
+
+ @ViewBuilder
+ private var mainContent: some View {
+ if let snapshot = store.snapshot {
+ if snapshot.isPremium {
+ tagList(snapshot)
+ } else {
+ lockedView
+ }
+ } else {
+ VStack(spacing: 8) {
+ ProgressView()
+ Text("iPhone과 연결 중...")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ private var lockedView: some View {
+ VStack(spacing: 6) {
+ Image(systemName: "crown.fill")
+ .foregroundStyle(.yellow)
+ Text("프리미엄 기능")
+ .font(.headline)
+ Text("iPhone 앱의 설정 → 프리미엄에서 잠금 해제해 주세요")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+
+ // MARK: 꼬리표 목록
+
+ private func tagList(_ snapshot: WatchSnapshot) -> some View {
+ List {
+ if let name = snapshot.runningName {
+ Section {
+ HStack(spacing: 6) {
+ Image(systemName: snapshot.runningSymbol ?? "record.circle")
+ .foregroundStyle(.yellow)
+ VStack(alignment: .leading, spacing: 0) {
+ Text(name)
+ .font(.footnote.weight(.semibold))
+ .lineLimit(1)
+ if let start = snapshot.runningStartAt {
+ Text(start, style: .timer)
+ .font(.footnote.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ if snapshot.extraRunningCount > 0 {
+ Text("+\(snapshot.extraRunningCount)")
+ .font(.caption2.weight(.bold))
+ .foregroundStyle(.secondary)
+ }
+ }
+ } header: {
+ Text("측정 중")
+ }
+ }
+ Section {
+ ForEach(snapshot.tags) { tag in
+ let actions = snapshot.actions.filter { $0.tagIDs.contains(tag.id) }
+ if !actions.isEmpty {
+ NavigationLink {
+ WatchActionListView(title: tag.name, actions: actions)
+ } label: {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(Color(watchHex: tag.colorHex))
+ .frame(width: 10, height: 10)
+ Text(tag.name)
+ .lineLimit(1)
+ Spacer()
+ Text("\(actions.count)")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ let untagged = snapshot.actions.filter { $0.tagIDs.isEmpty }
+ if !untagged.isEmpty {
+ NavigationLink {
+ WatchActionListView(title: "꼬리표 없음", actions: untagged)
+ } label: {
+ HStack(spacing: 8) {
+ Circle()
+ .strokeBorder(.secondary, lineWidth: 1)
+ .frame(width: 10, height: 10)
+ Text("꼬리표 없음")
+ Spacer()
+ Text("\(untagged.count)")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ } footer: {
+ if store.lastSendFailed {
+ Text("iPhone에 연결할 수 없어요. 근처에 있는지 확인해 주세요.")
+ }
+ }
+ }
+ }
+}
+
+// MARK: - 행동 목록
+
+struct WatchActionListView: View {
+ @State private var store = WatchStore.shared
+ let title: String
+ let actions: [WatchActionInfo]
+
+ /// 최신 스냅숏의 상태로 갱신된 행동 목록 (실행 후 반영)
+ private var currentActions: [WatchActionInfo] {
+ guard let snapshot = store.snapshot else { return actions }
+ return actions.compactMap { old in
+ snapshot.actions.first { $0.id == old.id } ?? old
+ }
+ }
+
+ var body: some View {
+ List {
+ ForEach(currentActions) { action in
+ Button {
+ store.run(action)
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: action.symbolName)
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(.white)
+ .frame(width: 28, height: 28)
+ .background(
+ Color(watchHex: action.colorHex),
+ in: RoundedRectangle(cornerRadius: 8, style: .continuous)
+ )
+ VStack(alignment: .leading, spacing: 0) {
+ Text(action.name)
+ .font(.footnote.weight(.semibold))
+ .lineLimit(1)
+ valueText(action)
+ .font(.caption2.monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ if action.isRunning {
+ Image(systemName: "record.circle")
+ .font(.caption)
+ .foregroundStyle(.yellow)
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle(title)
+ }
+
+ @ViewBuilder
+ private func valueText(_ action: WatchActionInfo) -> some View {
+ if action.isCount {
+ Text("오늘 \(Int(action.todayValue))회")
+ } else if let base = action.tickingBase {
+ Text(base, style: .timer)
+ } else {
+ Text("오늘 \(WatchFormat.duration(action.todayValue))")
+ }
+ }
+}
+
+/// 워치 전용 간단 포매터
+enum WatchFormat {
+ static func duration(_ seconds: Double) -> String {
+ let total = Int(seconds.rounded())
+ let h = total / 3600
+ let m = (total % 3600) / 60
+ if h > 0 { return m > 0 ? "\(h)시간 \(m)분" : "\(h)시간" }
+ if m > 0 { return "\(m)분" }
+ return "\(total)초"
+ }
+}
diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements b/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements
new file mode 100644
index 0000000..64b9130
--- /dev/null
+++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.yechan.HaruDanim
+
+
+
diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatchApp.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatchApp.swift
new file mode 100644
index 0000000..bd97eea
--- /dev/null
+++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/Haru_DanimWatchApp.swift
@@ -0,0 +1,17 @@
+//
+// Haru_DanimWatchApp.swift
+// Haru_DanimWatch Watch App
+//
+// Created by 송예찬 on 7/10/26.
+//
+
+import SwiftUI
+
+@main
+struct Haru_DanimWatch_Watch_AppApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift
new file mode 100644
index 0000000..fb5bd03
--- /dev/null
+++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/WatchStore.swift
@@ -0,0 +1,132 @@
+//
+// WatchStore.swift
+// Haru_DanimWatch Watch App
+//
+// iPhone과의 WCSession 연동 상태 저장소 (CLAUDE.md §9.1)
+// - iPhone이 보내는 스냅숏을 보관하고, 탭 명령을 iPhone으로 보낸다
+// - 최신 스냅숏은 App Group defaults에 캐시해 컴플리케이션이 읽게 한다
+//
+
+import Foundation
+import Observation
+import SwiftUI
+import WatchConnectivity
+import WidgetKit
+
+@MainActor
+@Observable
+final class WatchStore: NSObject {
+ static let shared = WatchStore()
+
+ private(set) var snapshot: WatchSnapshot?
+ private(set) var isReachable = false
+ /// 마지막 명령 전송 실패 여부 (iPhone 연결 안내용)
+ private(set) var lastSendFailed = false
+
+ static let groupDefaults = UserDefaults(suiteName: "group.com.yechan.HaruDanim") ?? .standard
+
+ func activate() {
+ guard WCSession.isSupported() else { return }
+ let session = WCSession.default
+ session.delegate = self
+ session.activate()
+ // 이전 세션에서 받은 컨텍스트/캐시로 우선 표시
+ if let data = session.receivedApplicationContext[WatchSync.snapshotKey] as? Data,
+ let decoded = WatchSnapshot.decode(data) {
+ apply(decoded)
+ } else if let data = Self.groupDefaults.data(forKey: WatchSync.cachedSnapshotKey),
+ let decoded = WatchSnapshot.decode(data) {
+ apply(decoded)
+ }
+ refresh()
+ }
+
+ /// iPhone에 최신 상태 요청
+ func refresh() {
+ send([WatchSync.commandKey: WatchSync.refreshCommand])
+ }
+
+ /// 행동 실행 (시간형 토글 / 횟수형 +1)
+ func run(_ action: WatchActionInfo) {
+ send([
+ WatchSync.commandKey: WatchSync.runCommand,
+ WatchSync.actionIDKey: action.id.uuidString,
+ ])
+ }
+
+ private func send(_ message: [String: Any]) {
+ let session = WCSession.default
+ guard session.activationState == .activated else { return }
+ session.sendMessage(message) { reply in
+ Task { @MainActor in
+ self.lastSendFailed = false
+ if let data = reply[WatchSync.snapshotKey] as? Data,
+ let decoded = WatchSnapshot.decode(data) {
+ self.apply(decoded)
+ }
+ }
+ } errorHandler: { _ in
+ Task { @MainActor in
+ self.lastSendFailed = true
+ }
+ }
+ }
+
+ fileprivate func apply(_ new: WatchSnapshot) {
+ // 오래된 스냅숏으로 되돌아가지 않게 생성 시각 비교
+ if let current = snapshot, current.generatedAt > new.generatedAt { return }
+ snapshot = new
+ lastSendFailed = false
+ // 컴플리케이션용 캐시 + 타임라인 갱신
+ if let data = new.encoded() {
+ Self.groupDefaults.set(data, forKey: WatchSync.cachedSnapshotKey)
+ }
+ WidgetCenter.shared.reloadAllTimelines()
+ }
+}
+
+extension WatchStore: WCSessionDelegate {
+ nonisolated func session(
+ _ session: WCSession,
+ activationDidCompleteWith activationState: WCSessionActivationState,
+ error: (any Error)?
+ ) {
+ Task { @MainActor in
+ WatchStore.shared.isReachable = session.isReachable
+ WatchStore.shared.refresh()
+ }
+ }
+
+ nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
+ let reachable = session.isReachable
+ Task { @MainActor in
+ WatchStore.shared.isReachable = reachable
+ if reachable { WatchStore.shared.refresh() }
+ }
+ }
+
+ /// iPhone이 미는 최신 상태
+ nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
+ guard let data = applicationContext[WatchSync.snapshotKey] as? Data,
+ let decoded = WatchSnapshot.decode(data) else { return }
+ Task { @MainActor in
+ WatchStore.shared.apply(decoded)
+ }
+ }
+}
+
+// MARK: - 색 변환 (워치 타깃 전용)
+
+extension Color {
+ /// "#RRGGBB" hex 문자열로 생성 (iOS 쪽 Theme.swift와 동일 규칙)
+ init(watchHex hex: String) {
+ let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
+ var value: UInt64 = 0
+ Scanner(string: cleaned).scanHexInt64(&value)
+ self.init(
+ red: Double((value >> 16) & 0xFF) / 255,
+ green: Double((value >> 8) & 0xFF) / 255,
+ blue: Double(value & 0xFF) / 255
+ )
+ }
+}
diff --git a/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift b/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift
new file mode 100644
index 0000000..a62ddf1
--- /dev/null
+++ b/myApp/HaruDanim/IOS/Core/WatchSyncManager.swift
@@ -0,0 +1,178 @@
+//
+// WatchSyncManager.swift
+// Haru_Danim
+//
+// Apple Watch 실시간 연동 (CLAUDE.md §9.1)
+// - 워치의 실행 명령을 받아 DB에 반영하고 최신 스냅숏을 돌려준다
+// - 데이터가 바뀔 때마다 updateApplicationContext로 최신 상태를 밀어 넣는다
+//
+
+import Foundation
+import SwiftData
+import WatchConnectivity
+import WidgetKit
+
+@MainActor
+final class WatchSyncManager: NSObject {
+ static let shared = WatchSyncManager()
+
+ func activate() {
+ guard WCSession.isSupported() else { return }
+ let session = WCSession.default
+ session.delegate = self
+ session.activate()
+ }
+
+ // MARK: 스냅숏 생성
+
+ /// 현재 DB 상태로 워치용 스냅숏 생성
+ func makeSnapshot() -> WatchSnapshot {
+ let context = DataStore.shared.mainContext
+ let now = Date.now
+ let math = DayMath()
+ let agg = Aggregator(math: math)
+
+ var snapshot = WatchSnapshot()
+ snapshot.isPremium = PremiumGate.isUnlocked(.watchApp)
+
+ let tags = (try? context.fetch(FetchDescriptor(
+ sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
+ ))) ?? []
+ snapshot.tags = tags.map { WatchTagInfo(id: $0.uuid, name: $0.name, colorHex: $0.colorHex) }
+
+ let actions = (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]))) ?? []
+ snapshot.actions = actions.map { action in
+ let todayValue = agg.todayValue(for: action, now: now)
+ let isCount = action.trackingType == .count
+ return WatchActionInfo(
+ id: action.uuid,
+ name: action.name,
+ symbolName: action.symbolName,
+ colorHex: action.sortedTags.first?.colorHex ?? "#2F6B4F",
+ isCount: isCount,
+ todayValue: todayValue,
+ isRunning: action.isRunning,
+ tickingBase: action.isRunning && !isCount ? now.addingTimeInterval(-todayValue) : nil,
+ tagIDs: action.sortedTags.map(\.uuid)
+ )
+ }
+
+ let goals = (try? context.fetch(FetchDescriptor(
+ predicate: #Predicate { $0.statusRaw == "inProgress" },
+ sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)]
+ ))) ?? []
+ snapshot.goals = goals.map { goal in
+ WatchGoalInfo(
+ id: goal.uuid,
+ title: goal.title,
+ symbolName: goal.symbolName,
+ colorHex: goal.colorHex,
+ dayRatio: goal.combinedSpanRatio(.day, math: math, now: now),
+ weekRatio: goal.combinedSpanRatio(.week, math: math, now: now),
+ monthRatio: goal.combinedSpanRatio(.month, math: math, now: now),
+ quests: goal.sortedQuests.map { quest in
+ let progress = QuestProgress(quest: quest, math: math)
+ let day = progress.spanProgress(.day, now: now)
+ let week = progress.spanProgress(.week, now: now)
+ let month = progress.spanProgress(.month, now: now)
+ return WatchQuestInfo(
+ id: quest.uuid,
+ name: quest.targetName,
+ symbolName: quest.targetSymbol,
+ colorHex: quest.targetAction?.sortedTags.first?.colorHex
+ ?? quest.targetTag?.colorHex ?? "#2F6B4F",
+ dayRatio: day.ratio,
+ weekRatio: week.ratio,
+ monthRatio: month.ratio,
+ isAtMost: quest.direction == .atMost,
+ dayAchieved: day.isAchieved,
+ weekAchieved: week.isAchieved,
+ monthAchieved: month.isAchieved
+ )
+ }
+ )
+ }
+
+ // 현재 측정 중 대표 행동 (다이나믹 아일랜드와 동일 기준)
+ let running = (try? context.fetch(FetchDescriptor(
+ predicate: #Predicate { $0.endAt == nil },
+ sortBy: [SortDescriptor(\.startAt, order: .forward)]
+ ))) ?? []
+ let mode = LiveActivityMode(
+ rawValue: AppGroup.defaults.string(forKey: SettingsKeys.liveActivityMode)
+ ?? UserDefaults.standard.string(forKey: SettingsKeys.liveActivityMode) ?? ""
+ ) ?? .latest
+ if let representative = mode == .earliest ? running.first : running.last,
+ let action = representative.action {
+ snapshot.runningName = action.name
+ snapshot.runningSymbol = action.symbolName
+ snapshot.runningStartAt = representative.startAt
+ snapshot.extraRunningCount = max(0, running.count - 1)
+ }
+
+ return snapshot
+ }
+
+ /// 최신 스냅숏을 워치로 전송 (데이터 변경 시마다 호출)
+ func pushSnapshot() {
+ guard WCSession.isSupported() else { return }
+ let session = WCSession.default
+ guard session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return }
+ guard let data = makeSnapshot().encoded() else { return }
+ try? session.updateApplicationContext([WatchSync.snapshotKey: data])
+ }
+
+ // MARK: 명령 처리
+
+ /// 워치의 명령을 처리하고 최신 스냅숏 응답을 만든다
+ fileprivate func handle(command: String?, actionID: String?) -> [String: Any] {
+ let context = DataStore.shared.mainContext
+ if command == WatchSync.runCommand,
+ let idString = actionID,
+ let id = UUID(uuidString: idString),
+ PremiumGate.isUnlocked(.watchApp) {
+ let actions = (try? context.fetch(FetchDescriptor())) ?? []
+ if let action = actions.first(where: { $0.uuid == id }) {
+ ActionRunner.run(action, context: context)
+ try? context.save()
+ LiveActivityManager.sync(context: context)
+ WidgetCenter.shared.reloadAllTimelines()
+ }
+ }
+ guard let data = makeSnapshot().encoded() else { return [:] }
+ return [WatchSync.snapshotKey: data]
+ }
+}
+
+extension WatchSyncManager: WCSessionDelegate {
+ nonisolated func session(
+ _ session: WCSession,
+ activationDidCompleteWith activationState: WCSessionActivationState,
+ error: (any Error)?
+ ) {
+ Task { @MainActor in
+ WatchSyncManager.shared.pushSnapshot()
+ }
+ }
+
+ nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
+
+ nonisolated func sessionDidDeactivate(_ session: WCSession) {
+ session.activate()
+ }
+
+ /// 워치의 실행/새로 고침 요청 (응답으로 최신 스냅숏 반환)
+ nonisolated func session(
+ _ session: WCSession,
+ didReceiveMessage message: [String: Any],
+ replyHandler: @escaping ([String: Any]) -> Void
+ ) {
+ let command = message[WatchSync.commandKey] as? String
+ let actionID = message[WatchSync.actionIDKey] as? String
+ Task { @MainActor in
+ let reply = WatchSyncManager.shared.handle(command: command, actionID: actionID)
+ replyHandler(reply)
+ WatchSyncManager.shared.pushSnapshot()
+ }
+ }
+}
diff --git a/myApp/HaruDanim/WatchShared/WatchPayload.swift b/myApp/HaruDanim/WatchShared/WatchPayload.swift
new file mode 100644
index 0000000..4f63aef
--- /dev/null
+++ b/myApp/HaruDanim/WatchShared/WatchPayload.swift
@@ -0,0 +1,118 @@
+//
+// WatchPayload.swift
+// Haru_Danim
+//
+// iPhone ↔ Apple Watch 동기화 페이로드 (CLAUDE.md §9)
+// - iOS 앱 타깃 + 워치 앱 타깃 + 워치 위젯 확장에 함께 포함되는 순수 Codable 구조체
+// - iPhone이 스냅숏을 만들어 WCSession으로 보내고, 워치는 UI 표시와
+// 컴플리케이션(App Group defaults에 캐시)에 사용한다
+//
+
+import Foundation
+
+nonisolated enum WatchSync {
+ /// WCSession 메시지/컨텍스트에서 스냅숏 Data가 담기는 키
+ static let snapshotKey = "snapshot"
+ /// 워치 App Group defaults에 최신 스냅숏을 캐시하는 키 (컴플리케이션용)
+ static let cachedSnapshotKey = "watch.cachedSnapshot"
+ /// 메시지 명령 키
+ static let commandKey = "command"
+ static let actionIDKey = "actionID"
+ /// 명령 종류
+ static let runCommand = "run"
+ static let refreshCommand = "refresh"
+}
+
+/// 워치에 보여줄 전체 상태 스냅숏
+nonisolated struct WatchSnapshot: Codable {
+ var generatedAt: Date = .now
+ var isPremium: Bool = false
+ var tags: [WatchTagInfo] = []
+ var actions: [WatchActionInfo] = []
+ var goals: [WatchGoalInfo] = []
+ /// 현재 측정 중 대표 행동 (다이나믹 아일랜드와 동일 기준, CLAUDE.md §9.2-1)
+ var runningName: String?
+ var runningSymbol: String?
+ var runningStartAt: Date?
+ /// 대표 외에 함께 측정 중인 행동 개수
+ var extraRunningCount: Int = 0
+
+ func encoded() -> Data? {
+ try? JSONEncoder().encode(self)
+ }
+
+ static func decode(_ data: Data) -> WatchSnapshot? {
+ try? JSONDecoder().decode(WatchSnapshot.self, from: data)
+ }
+}
+
+nonisolated struct WatchTagInfo: Codable, Identifiable, Hashable {
+ var id: UUID
+ var name: String
+ var colorHex: String
+}
+
+nonisolated struct WatchActionInfo: Codable, Identifiable, Hashable {
+ var id: UUID
+ var name: String
+ var symbolName: String
+ var colorHex: String
+ var isCount: Bool
+ /// 오늘(논리적 하루) 누적값 — 시간형은 초, 횟수형은 회 (스냅숏 시점 기준)
+ var todayValue: Double
+ var isRunning: Bool
+ /// 측정 중일 때 실시간 타이머 기준 시각 (스냅숏 시점 - 오늘 누적초)
+ var tickingBase: Date?
+ /// 소속 꼬리표 id들 (비어 있으면 '꼬리표 없음' 그룹)
+ var tagIDs: [UUID]
+}
+
+nonisolated struct WatchGoalInfo: Codable, Identifiable, Hashable {
+ var id: UUID
+ var title: String
+ var symbolName: String
+ var colorHex: String
+ var dayRatio: Double
+ var weekRatio: Double
+ var monthRatio: Double
+ var quests: [WatchQuestInfo]
+
+ func ratio(forSpanRaw raw: String) -> Double {
+ switch raw {
+ case "week": return weekRatio
+ case "month": return monthRatio
+ default: return dayRatio
+ }
+ }
+}
+
+nonisolated struct WatchQuestInfo: Codable, Identifiable, Hashable {
+ var id: UUID
+ var name: String
+ var symbolName: String
+ var colorHex: String
+ var dayRatio: Double
+ var weekRatio: Double
+ var monthRatio: Double
+ /// '이하 유지' 다짐 여부 (달성 표현을 다르게, CLAUDE.md §9.2-3)
+ var isAtMost: Bool
+ var dayAchieved: Bool
+ var weekAchieved: Bool
+ var monthAchieved: Bool
+
+ func ratio(forSpanRaw raw: String) -> Double {
+ switch raw {
+ case "week": return weekRatio
+ case "month": return monthRatio
+ default: return dayRatio
+ }
+ }
+
+ func achieved(forSpanRaw raw: String) -> Bool {
+ switch raw {
+ case "week": return weekAchieved
+ case "month": return monthAchieved
+ default: return dayAchieved
+ }
+ }
+}