From 42b95c5d7fe48b1244059a9590f2e84f147816cb Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Mon, 13 Jul 2026 23:49:45 +0900 Subject: [PATCH] feat(quest): consecutive-achievement display on quests, plus wording fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다짐 단위 '연속 달성' 표기 추가 (QuestProgress.streak → QuestStreakInfo): - 하루 단위 다짐: 적용일만 세는 연속 계산 — 매일이면 연속 일수, '매주 수요일'이면 수요일들만 이어서 센다 (4주 연속 = 연속 4일) - 주간/월간 다짐: 주기 전체 달성 여부로 연속 N주/N달 - 특정 기간 다짐: 반복이 없어 표시하지 않음 - 진행 중인 오늘/이번 주기는 '이상 달성'이면 미달이어도 끊기지 않고 건너뛰며, 채웠으면 포함. '이하 유지'는 한도를 넘는 순간 끊김 - 성능: 기록을 논리적 하루 키로 1회 버킷팅해 O(기록수+일수), 소급 상한 400일 표기(기존 디자인·데이터는 그대로 두고 추가만, 0이면 숨김): - 목표 탭 다짐 행: 이름 옆 불꽃 + "연속 N일" 배지 (노랑) - 위젯 ③ 다짐 진행률: 퍼센트 아래 한 줄 - 위젯 ④ 다짐 현황: 이름 아래 작은 글씨 - 위젯 ② 목표+다짐 구성: 퍼센트 아래 6.5pt — 연속 표기가 있을 때만 행 높이 14→19 (적응 채움이 자동 반영) - 문구는 '스트릭' 대신 "연속 N일/주/달" (en은 streak, ja는 N日連続) 문구 정정: '몇째 주 요일' → '몇 번째 요일' — 구현(weekdayOrdinal)은 달력의 주차가 아니라 "그 달의 n번째 ◯요일"이므로 편집 화면 픽커와 주기 요약 문구를 실제 동작에 맞게 수정 (동작 변경 없음). en/ja 번역 포함. 검증: 시드 데이터로 목표 탭 배지 손계산 대조(독서 연속 3일 — 3일 전 기록 없음 / 영상 시청 어제 한도 초과로 연속 1일 등 전부 일치), 위젯 ②③④ 미리보기 스크린샷으로 레이아웃 넘침 없음 확인, missing: [], Debug·Store 스킴 빌드 성공. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/CLAUDE.md | 3 +- myApp/HaruDanim/IOS/Localizable.xcstrings | 122 ++++++++++++++++ myApp/HaruDanim/IOS/Views/GoalViews.swift | 17 +++ .../HaruDanim/IOS/Views/QuestEditorView.swift | 5 +- myApp/HaruDanim/Shared/Models.swift | 6 +- myApp/HaruDanim/Shared/QuestProgress.swift | 134 ++++++++++++++++++ myApp/HaruDanim/Widgets/GoalWidgets.swift | 47 ++++-- myApp/HaruDanim/Widgets/QuestRingWidget.swift | 10 ++ myApp/HaruDanim/Widgets/WidgetSupport.swift | 21 ++- 9 files changed, 344 insertions(+), 21 deletions(-) diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index b9a3edd..8f2c363 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -166,7 +166,8 @@ xcrun xcstringstool sync IOS/Localizable.xcstrings --stringsdata "${files[@]}" - 목표 행: 상태 뱃지(진행 중/달성/미달성/확인 필요) + 기간 진행률 바(종료일 있을 때). "확인 필요"는 탭해서 달성/미달성 직접 선택 - 목표 편집: 내용·아이콘·색·시작일(과거 가능)·종료일(선택)·**달성 판정 기준 슬라이더(10~100%, 5% 단위, 기본 100%)** - 목표 상세: 다짐 목록(탭=수정, 스와이프 삭제, 순서 모드), 다짐 추가(무료 한도 3개), 종료일 없으면 수동 종료(기준<100%면 안내 문구에 기준 표기), 삭제 -- 다짐 편집 3단계: ①대상(행동 또는 꼬리표+측정 기준) ②주기(하루[매일/요일/날짜/몇째주 요일]·주·월·특정 기간) ③목표량(시간 휠 또는 횟수 스테퍼)+방향(이상 달성/이하 유지) +- 다짐 편집 3단계: ①대상(행동 또는 꼬리표+측정 기준) ②주기(하루[매일/요일/날짜/n번째 요일]·주·월·특정 기간 — ordinalWeekday 구현은 weekdayOrdinal 즉 "그 달의 n번째 ◯요일"이며 UI 문구도 그렇게 표기) ③목표량(시간 휠 또는 횟수 스테퍼)+방향(이상 달성/이하 유지) +- **연속 달성**(`QuestProgress.streak` → `QuestStreakInfo`): 하루 단위=적용일만 연속 계산("연속 N일" — 매주 수요일 다짐을 4주 연속이면 연속 4일), 주간/월간="연속 N주/N달", 특정 기간=nil. 진행 중인 오늘/이번 주기는 atLeast면 미달이어도 끊기지 않고(건너뜀) 채웠으면 포함, atMost는 넘는 순간 끊김. 소급 상한 400일(성능). 기록을 하루 키로 1회 버킷팅해 O(기록+일수). 표기: 목표 탭 다짐 행 + 위젯 ②다짐 줄·③링 셀·④현황 그리드 (불꽃+노랑, 0이면 숨김). '스트릭'이라는 단어는 한국어 UI에 쓰지 않는다 - 목록 진입 시 `evaluateIfEnded` 일괄 실행 ### 6.5 기록 탭 (`HistoryView`, `RecordEditors`) diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index 0594f86..8ad22e5 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -602,6 +602,22 @@ } } }, + "%lld번째": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "#%lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld番目" + } + } + } + }, "%lld분": { "localizations": { "en": { @@ -689,6 +705,7 @@ } }, "%lld째 주": { + "extractionState": "stale", "localizations": { "en": { "stringUnit": { @@ -3431,7 +3448,30 @@ } } }, + "매달 %lld번째 %@요일": { + "localizations": { + "ko": { + "stringUnit": { + "state": "new", + "value": "매달 %1$lld번째 %2$@요일" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "#%lld %@ of every month" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "毎月第%lld%@曜日" + } + } + } + }, "매달 %lld째 주 %@요일": { + "extractionState": "stale", "localizations": { "en": { "stringUnit": { @@ -3549,7 +3589,40 @@ } } }, + "몇 번째": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Occurrence" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "何番目" + } + } + } + }, + "몇 번째 요일": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Nth weekday" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "何番目の曜日" + } + } + } + }, "몇째 주": { + "extractionState": "stale", "localizations": { "en": { "stringUnit": { @@ -3566,6 +3639,7 @@ } }, "몇째 주 요일": { + "extractionState": "stale", "localizations": { "en": { "stringUnit": { @@ -5944,6 +6018,54 @@ } } }, + "연속 %lld달": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld-month streak" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lldか月連続" + } + } + } + }, + "연속 %lld일": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld-day streak" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld日連続" + } + } + } + }, + "연속 %lld주": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld-week streak" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld週連続" + } + } + } + }, "영상 시청": { "localizations": { "en": { diff --git a/myApp/HaruDanim/IOS/Views/GoalViews.swift b/myApp/HaruDanim/IOS/Views/GoalViews.swift index 2169772..9420f8c 100644 --- a/myApp/HaruDanim/IOS/Views/GoalViews.swift +++ b/myApp/HaruDanim/IOS/Views/GoalViews.swift @@ -313,6 +313,12 @@ struct GoalRow: View { struct QuestRow: View { let quest: Quest + /// 연속 달성 (1 이상일 때만 표기 — 끊기면 배지가 사라진다) + private var streakLabel: String? { + guard let streak = QuestProgress(quest: quest).streak(), streak.count > 0 else { return nil } + return streak.label + } + var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { @@ -323,6 +329,17 @@ struct QuestRow: View { .background(quest.targetColor, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) Text(quest.targetName) .font(.subheadline.weight(.medium)) + if let streakLabel { + HStack(spacing: 2) { + Image(systemName: "flame.fill") + .font(.system(size: 9, weight: .bold)) + Text(streakLabel) + .font(.caption2.weight(.semibold)) + } + .foregroundStyle(AppTheme.yellow) + .lineLimit(1) + .layoutPriority(1) + } Spacer() Text("\(quest.scheduleLabel) · \(quest.targetValueLabel) \(quest.direction.label)") .font(.caption2) diff --git a/myApp/HaruDanim/IOS/Views/QuestEditorView.swift b/myApp/HaruDanim/IOS/Views/QuestEditorView.swift index 34cb723..060c281 100644 --- a/myApp/HaruDanim/IOS/Views/QuestEditorView.swift +++ b/myApp/HaruDanim/IOS/Views/QuestEditorView.swift @@ -142,9 +142,10 @@ struct QuestEditorView: View { case .monthDays: monthDayPicker case .ordinalWeekday: - Picker("몇째 주", selection: $ordinalWeek) { + // "n번째 ◯요일" (weekdayOrdinal) — 달력의 'n째 주'와 다를 수 있어 문구를 맞춘다 + Picker("몇 번째", selection: $ordinalWeek) { ForEach(1...5, id: \.self) { n in - Text("\(n)째 주").tag(n) + Text("\(n)번째").tag(n) } } Picker("요일", selection: $ordinalWeekday) { diff --git a/myApp/HaruDanim/Shared/Models.swift b/myApp/HaruDanim/Shared/Models.swift index b477f75..d5f28af 100644 --- a/myApp/HaruDanim/Shared/Models.swift +++ b/myApp/HaruDanim/Shared/Models.swift @@ -332,7 +332,8 @@ enum QuestScheduleMode: String, CaseIterable, Identifiable { case .everyDay: return String(localized: "매일") case .weekdays: return String(localized: "요일 지정") case .monthDays: return String(localized: "날짜 지정") - case .ordinalWeekday: return String(localized: "몇째 주 요일") + // 구현은 weekdayOrdinal(그 달의 n번째 ◯요일) — '셋째 주'와 다를 수 있어 문구를 정확히 맞춘다 + case .ordinalWeekday: return String(localized: "몇 번째 요일") } } } @@ -462,7 +463,8 @@ extension Quest { let names = monthDays.sorted().map { String(localized: "\($0)일") } return String(localized: "매달 \(names.joined(separator: ", "))") case .ordinalWeekday: - return String(localized: "매달 \(ordinalWeek)째 주 \(Format.weekdayShort(ordinalWeekday))요일") + // weekdayOrdinal 기준이므로 "n째 주"가 아니라 "n번째 ◯요일" (예: 매달 3번째 화요일) + return String(localized: "매달 \(ordinalWeek)번째 \(Format.weekdayShort(ordinalWeekday))요일") } case .weekly: return String(localized: "매주") diff --git a/myApp/HaruDanim/Shared/QuestProgress.swift b/myApp/HaruDanim/Shared/QuestProgress.swift index 8f8f888..f9e89ae 100644 --- a/myApp/HaruDanim/Shared/QuestProgress.swift +++ b/myApp/HaruDanim/Shared/QuestProgress.swift @@ -221,6 +221,140 @@ struct QuestProgress { } } +// MARK: - 연속 달성 + +/// 다짐의 연속 달성 정보. 문구는 "연속 3일 / 연속 3주 / 연속 2달" 형태만 사용한다 (스펙). +struct QuestStreakInfo { + enum Unit { + case day, week, month + } + + let count: Int + let unit: Unit + + var label: String { + switch unit { + case .day: return String(localized: "연속 \(count)일") + case .week: return String(localized: "연속 \(count)주") + case .month: return String(localized: "연속 \(count)달") + } + } +} + +extension QuestProgress { + /// 연속 계산이 과거로 거슬러 올라가는 상한(일). 성능 보호용 — + /// 이보다 긴 연속은 상한에서 잘린 값으로 표기된다 (하루 단위 400일 / 주 57주 / 달 13달 수준). + private static let streakScanDays = 400 + + /// 현재 연속 달성 횟수. 표시 규칙: + /// - 하루 단위: 적용일(매일/요일/날짜/몇 번째 요일)만 세어 "연속 N일". + /// 예: 매주 수요일 다짐을 4주 연속 수행 → 연속 4일. + /// - 주간/월간: 주기 전체 달성 여부로 "연속 N주 / N달". + /// - 특정 기간: 반복이 없으므로 nil (표시 안 함). + /// 진행 중인 오늘/이번 주기는 '이상 달성'이면 아직 못 채워도 끊기지 않고(건너뜀), + /// 이미 채웠으면 포함한다. '이하 유지'는 한도를 넘는 순간 끊김, 한도 안이면 포함. + func streak(now: Date = .now) -> QuestStreakInfo? { + guard quest.targetValue > 0 else { return nil } + switch quest.period { + case .custom: return nil + case .daily: return dayStreak(now: now) + case .weekly: return periodStreak(unit: .week, now: now) + case .monthly: return periodStreak(unit: .month, now: now) + } + } + + /// 스캔 구간의 기록을 논리적 하루 키로 한 번에 버킷팅한다. + /// 하루마다 Aggregator로 전체 기록을 다시 훑는 대신(O(일수×기록수)) 기록을 한 번만 지나가 + /// 연속 계산 전체가 O(기록수 + 일수)가 되게 하는 것이 핵심. + private func perDayValues(since lowerBound: Date, now: Date) -> [Date: Double] { + var values: [Date: Double] = [:] + for action in quest.targetActions { + switch quest.measure { + case .time: + for session in action.sessions { + let end = min(session.endAt ?? now, now) + let start = max(session.startAt, lowerBound) + guard start < end else { continue } + for segment in math.splitByDay(start: start, end: end) { + values[segment.dayKey, default: 0] + += segment.range.upperBound.timeIntervalSince(segment.range.lowerBound) + } + } + case .count: + for entry in action.countEntries + where entry.timestamp >= lowerBound && entry.timestamp <= now { + values[math.dayKey(for: entry.timestamp), default: 0] += Double(entry.amount) + } + } + } + return values + } + + private func isAchievedValue(_ value: Double) -> Bool { + switch quest.direction { + case .atLeast: return value >= quest.targetValue + case .atMost: return value <= quest.targetValue + } + } + + /// 하루 단위: 오늘(적용일)부터 거꾸로 적용일만 세며 연속 달성 계산 + private func dayStreak(now: Date) -> QuestStreakInfo { + let cal = math.calendar + let todayKey = math.dayKey(for: now) + let lowerKey = cal.date(byAdding: .day, value: -Self.streakScanDays, to: todayKey)! + let values = perDayValues(since: math.dayRange(forKey: lowerKey).lowerBound, now: now) + + var count = 0 + var key = todayKey + while key >= lowerKey { + if isActiveDay(key) { + if isAchievedValue(values[key] ?? 0) { + count += 1 + } else if key == todayKey && quest.direction == .atLeast { + // 오늘은 아직 하루가 끝나지 않았으므로 미달이어도 끊김이 아님 — 어제까지로 센다 + } else { + break + } + } + key = cal.date(byAdding: .day, value: -1, to: key)! + } + return QuestStreakInfo(count: count, unit: .day) + } + + /// 주간/월간: 현재 주기부터 거꾸로 주기 전체 달성 여부로 연속 계산 + private func periodStreak(unit: QuestStreakInfo.Unit, now: Date) -> QuestStreakInfo { + let currentRange = unit == .week ? math.weekRange(containing: now) : math.monthRange(containing: now) + let lowerBound = math.calendar.date( + byAdding: .day, value: -Self.streakScanDays, to: currentRange.lowerBound + )! + let values = perDayValues(since: lowerBound, now: now) + + func value(in range: Range) -> Double { + math.dayKeys(in: range).reduce(0) { $0 + (values[$1] ?? 0) } + } + + var count = 0 + var range = currentRange + var isCurrentPeriod = true + while range.lowerBound >= lowerBound { + if isAchievedValue(value(in: range)) { + count += 1 + } else if isCurrentPeriod && quest.direction == .atLeast { + // 진행 중인 이번 주기의 미달은 끊김이 아님 — 지난 주기부터 센다 + } else { + break + } + isCurrentPeriod = false + // 이전 주기: 현재 주기 시작 직전 시각이 속한 주/달 + let justBefore = range.lowerBound.addingTimeInterval(-1) + range = unit == .week + ? math.weekRange(containing: justBefore) + : math.monthRange(containing: justBefore) + } + return QuestStreakInfo(count: count, unit: unit) + } +} + // MARK: - 목표 평가 extension Goal { diff --git a/myApp/HaruDanim/Widgets/GoalWidgets.swift b/myApp/HaruDanim/Widgets/GoalWidgets.swift index 0b0f578..05b7006 100644 --- a/myApp/HaruDanim/Widgets/GoalWidgets.swift +++ b/myApp/HaruDanim/Widgets/GoalWidgets.swift @@ -156,7 +156,8 @@ struct GoalBarsCellView: View { } } -/// 다짐 1개의 하루 진행률 한 줄 (아이콘 + 이름 + 진행바 + %) +/// 다짐 1개의 하루 진행률 한 줄 (아이콘 + 이름 + 진행바 + %). +/// 연속 달성이 있으면 % 아래에 아주 작게 덧붙인다 (기존 열 폭·구성은 유지). struct QuestBarRow: View { let quest: QuestCellSnapshot @@ -173,10 +174,24 @@ struct QuestBarRow: View { ProgressView(value: min(max(quest.ratio, 0), 1)) .progressViewStyle(.linear) .tint(quest.isAtMost && !quest.isAchieved ? Color.red : quest.color) - Text(Format.percent(quest.displayRatio)) - .font(.system(size: 9, weight: .semibold).monospacedDigit()) - .foregroundStyle(.secondary) + if let streak = quest.streakLabel { + VStack(alignment: .trailing, spacing: 0) { + Text(Format.percent(quest.displayRatio)) + .font(.system(size: 9, weight: .semibold).monospacedDigit()) + .foregroundStyle(.secondary) + Text(streak) + .font(.system(size: 6.5, weight: .semibold)) + .foregroundStyle(AppTheme.yellow) + .lineLimit(1) + .minimumScaleFactor(0.8) + } .frame(width: 34, alignment: .trailing) + } else { + Text(Format.percent(quest.displayRatio)) + .font(.system(size: 9, weight: .semibold).monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 34, alignment: .trailing) + } } } } @@ -186,7 +201,11 @@ struct QuestBarRow: View { struct QuestBarsColumn: View { let quests: [QuestCellSnapshot] - private let rowHeight: CGFloat = 14 + /// 연속 달성 줄이 하나라도 있으면 행 높이를 살짝 키운다 (없으면 기존 14 그대로 — + /// 표시 개수 계산(fitCount)이 자동으로 따라오므로 넘치지 않는다) + private var rowHeight: CGFloat { + quests.contains { $0.streakLabel != nil } ? 19 : 14 + } private let rowSpacing: CGFloat = 6 private let headerHeight: CGFloat = 17 @@ -456,7 +475,7 @@ struct QuestStatusProvider: AppIntentTimelineProvider { } } -/// 링 + 이름만 (진행률 % 텍스트 없음) +/// 링 + 이름만 (진행률 % 텍스트 없음). 연속 달성이 있으면 이름 아래 아주 작게 덧붙인다. struct QuestRingCellView: View { let quest: QuestCellSnapshot var ringSize: CGFloat = 34 @@ -465,10 +484,18 @@ struct QuestRingCellView: View { VStack(spacing: 5) { QuestRingView(snapshot: quest, lineWidth: 4, iconSize: ringSize * 0.36) .frame(width: ringSize, height: ringSize) - Text(quest.targetName) - .font(.system(size: 8, weight: .medium)) - .lineLimit(1) - .foregroundStyle(.secondary) + VStack(spacing: 1) { + Text(quest.targetName) + .font(.system(size: 8, weight: .medium)) + .lineLimit(1) + .foregroundStyle(.secondary) + if let streak = quest.streakLabel { + Text(streak) + .font(.system(size: 7, weight: .semibold)) + .foregroundStyle(AppTheme.yellow) + .lineLimit(1) + } + } } } } diff --git a/myApp/HaruDanim/Widgets/QuestRingWidget.swift b/myApp/HaruDanim/Widgets/QuestRingWidget.swift index c83fb9f..7cab055 100644 --- a/myApp/HaruDanim/Widgets/QuestRingWidget.swift +++ b/myApp/HaruDanim/Widgets/QuestRingWidget.swift @@ -133,6 +133,16 @@ struct QuestRingActionCellView: View { Text(percentText) .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) + if let streak = cell.streakLabel { + HStack(spacing: 2) { + Image(systemName: "flame.fill") + .font(.system(size: 7, weight: .bold)) + Text(streak) + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(AppTheme.yellow) + .lineLimit(1) + } } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(fullBleed ? 10 : 6) diff --git a/myApp/HaruDanim/Widgets/WidgetSupport.swift b/myApp/HaruDanim/Widgets/WidgetSupport.swift index 94b5fde..cac17e4 100644 --- a/myApp/HaruDanim/Widgets/WidgetSupport.swift +++ b/myApp/HaruDanim/Widgets/WidgetSupport.swift @@ -93,12 +93,16 @@ struct QuestCellSnapshot: Identifiable { /// 실행 버튼 대상 (단일 행동 대상 다짐만) let runTarget: ActionEntity? let isRunning: Bool + /// 연속 달성 문구 ("연속 3일" 등). 1 미만이거나 특정 기간 다짐이면 nil (표시 생략) + let streakLabel: String? var color: Color { Color(hex: colorHex) } @MainActor static func make(quest: Quest, span: StatSpan, now: Date = .now) -> QuestCellSnapshot { - let result = QuestProgress(quest: quest).spanProgress(span, now: now) + let progress = QuestProgress(quest: quest) + let result = progress.spanProgress(span, now: now) + let streak = progress.streak(now: now) let action = quest.targetAction return QuestCellSnapshot( id: quest.uuid, @@ -111,7 +115,8 @@ struct QuestCellSnapshot: Identifiable { isAtMost: quest.direction == .atMost, isAchieved: result.isAchieved, runTarget: action.map(ActionEntity.init), - isRunning: action?.isRunning ?? false + isRunning: action?.isRunning ?? false, + streakLabel: (streak?.count ?? 0) > 0 ? streak?.label : nil ) } } @@ -174,16 +179,20 @@ extension QuestCellSnapshot { static let samples: [QuestCellSnapshot] = [ QuestCellSnapshot(id: UUID(), targetName: String(localized: "독서"), symbolName: "book.fill", colorHex: "#2F6B4F", ratio: 0.75, displayRatio: 0.75, - isAtMost: false, isAchieved: false, runTarget: nil, isRunning: true), + isAtMost: false, isAchieved: false, runTarget: nil, isRunning: true, + streakLabel: QuestStreakInfo(count: 5, unit: .day).label), QuestCellSnapshot(id: UUID(), targetName: String(localized: "달리기"), symbolName: "figure.run", colorHex: "#9D5B4A", ratio: 0.4, displayRatio: 0.4, - isAtMost: false, isAchieved: false, runTarget: nil, isRunning: false), + isAtMost: false, isAchieved: false, runTarget: nil, isRunning: false, + streakLabel: nil), QuestCellSnapshot(id: UUID(), targetName: String(localized: "물 마시기"), symbolName: "drop.fill", colorHex: "#4A7A9D", ratio: 1, displayRatio: 1.1, - isAtMost: false, isAchieved: true, runTarget: nil, isRunning: false), + isAtMost: false, isAchieved: true, runTarget: nil, isRunning: false, + streakLabel: QuestStreakInfo(count: 12, unit: .day).label), QuestCellSnapshot(id: UUID(), targetName: String(localized: "영상 시청"), symbolName: "play.rectangle.fill", colorHex: "#7A5C9D", ratio: 1, displayRatio: 1, - isAtMost: true, isAchieved: true, runTarget: nil, isRunning: false), + isAtMost: true, isAchieved: true, runTarget: nil, isRunning: false, + streakLabel: nil), ] }