feat(quest): consecutive-achievement display on quests, plus wording fixes

다짐 단위 '연속 달성' 표기 추가 (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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
This commit is contained in:
songyc macbook 2026-07-13 23:49:45 +09:00
parent 076dad26d8
commit 42b95c5d7f
9 changed files with 344 additions and 21 deletions

View File

@ -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`)

View File

@ -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": {

View File

@ -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)

View File

@ -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) {

View File

@ -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: "매주")

View File

@ -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<Date>) -> 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 {

View File

@ -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)
}
}
}
}
}

View File

@ -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)

View File

@ -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),
]
}