feat(quest): daily quest deadline time — clip the day's measurement window

하루 단위 다짐에 '마감 시각까지만 집계' 옵션 추가 (최소판 설계):

- 모델: Quest.deadlineMinutes(-1=없음) 필드 1개 — CloudKit 안전(기본값), 주기가
  하루 단위가 아니면 값이 남아도 무시(hasDeadline이 주기·범위를 함께 검사)
- 의미론: 마감 = 그날 집계 창의 끝. dayMeasurementRange(forKey:) 단일 구현을
  주기 범위(current/judgment)·주/월 span 하루 루프(spanValue)·연속 버킷팅
  (perDayValues)·위젯 누적 라벨(spanRawValue)·일기 카드가 공유한다.
  원본 기록은 불변 — 마감 뒤 기록은 남되 그 다짐에만 미집계. 시간형은 기존
  겹침 클리핑이 마감을 걸친 세션을 부분 인정(추가 코드 0). '이하 유지'는
  마감까지 한도 안이면 그날 달성으로 고정.
- 경계 규칙: 마감은 그 논리적 하루(24h) 안에서 그 시계 시각의 등장 지점 —
  하루 시작보다 이른 시각은 다음 달력일 새벽, 시작과 같은 시각은 +24h(온전한
  하루)로 0길이 창이 수학적으로 불가능(에디터 검증 불필요, 오류 상태 없음)
- 의도된 최소화: '마감 지남' 전용 표시 없음(게이지 값이 마감 시점에 연속이라
  위젯 타임라인 마감 경계 불필요 — 위젯·워치·시리는 코드 변경 0으로 자동 일관),
  연속 달성은 값 클리핑만(오늘 놓친 끊김 반영은 내일부터 — 유예 규칙 유지),
  주간/월간/기간 마감은 미지원
- UI: 다짐 에디터 토글+시간 선택(하루 단위만, footer 설명), 주기 문구에
  "· 오전 8:00까지" 표기(목표 탭·시리 요약·CSV 자동 반영), 일기 카드
  "하루 목표 · 오전 8:00까지", 도움말 항목 추가, ko/en/ja 번역
- 검증: seedDemo에 마감 다짐 시드(창 안/밖/어제 실패 케이스) — 하루 100%
  (창 밖 기록 포함 시 200%가 되는 함정 통과), 주간 29%(=2/7)·월간 6%(=2/31)
  수기 계산과 정확 일치, streak-dump 연속 1일(어제 실패로 끊김) 확인,
  마감 없는 다짐 전부 기존 수치 유지(경로가 hasDeadline 가드로 완전 동일),
  일기 카드 1회/1회(원본 합계는 그대로), 영어 로케일 "By 8:00 AM" 렌더,
  Debug·Store 빌드 성공, 카탈로그 9종 missing/stale 0

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-15 17:52:18 +09:00
parent b399ee02fe
commit 6a486a4fbd
10 changed files with 7703 additions and 7436 deletions

View File

@ -91,7 +91,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
| `TimeSession` | startAt, endAt(nil=측정 중), note | 삭제 규칙: Action cascade |
| `CountEntry` | timestamp, amount, note | |
| `Goal` | uuid, title, symbolName, colorHex, startDate, endDate?, statusRaw(inProgress/achieved/notAchieved), **achieveThresholdPercent(기본 100)**, sortOrder | isCollapsed·showsOnMain은 레거시 |
| `Quest` | uuid, goal, targetAction 또는 targetTag, measureRaw, periodRaw(daily/weekly/monthly/custom), scheduleModeRaw(everyDay/weekdays/monthDays/ordinalWeekday), weekdays[], monthDays[], ordinalWeek/Weekday, customStart/End, targetSeconds/targetCount, directionRaw(atLeast/atMost), sortOrder | 꼬리표 대상이면 measure는 생성 시 선택, `targetActions`는 해당 measure의 행동만 |
| `Quest` | uuid, goal, targetAction 또는 targetTag, measureRaw, periodRaw(daily/weekly/monthly/custom), scheduleModeRaw(everyDay/weekdays/monthDays/ordinalWeekday), weekdays[], monthDays[], ordinalWeek/Weekday, customStart/End, targetSeconds/targetCount, directionRaw(atLeast/atMost), **deadlineMinutes(-1=없음 — 하루 단위 전용 마감 시각, §4.2)**, sortOrder | 꼬리표 대상이면 measure는 생성 시 선택, `targetActions`는 해당 measure의 행동만 |
| `DiaryEntry` | dayKey(논리적 하루 키), moodEmoji, moodImageData(externalStorage), **calendarEventIDs[]**(그날 타임테이블에 넣을 EKEvent id), **hiddenActionIDs[]**(그날 타임테이블에서 숨길 Action.uuid 문자열), **hiddenGoalIDs[]**(그날 '이 날의 목표' 카드에서 숨길 Goal.uuid 문자열) | 날짜당 1개. 날짜별 선택은 uuid 문자열로 저장(기기 간 동기화 안전) |
| `DiaryTodo` | text, isDone, sortOrder | 요약 페이지 체크리스트 |
| `DiaryPage` | index, lined, lineSpacing, drawingData(PKDrawing, externalStorage), **templateID**(DiaryTemplate.uuid 문자열, 빈 값=없음), **templatePageIndex**(PDF 쪽, 0부터) | 768×1024 논리 좌표. 양식만 깔린 페이지도 hasContent=true(작성함 취급·내보내기 포함) |
@ -114,6 +114,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
- **주간·월간 span에서 '이상 달성' 하루 단위 다짐은 하루별 기여를 그날 목표량으로 캡** — 어느 날의 초과 달성이 다른 날 미달을 가리지 않음 (하루 span·atMost·주/월/기간 다짐은 캡 없음, 원본 기록 무영향)
- `scaledTarget`: 주기 목표량을 span 길이에 환산 (daily→적용일 수×목표, weekly→/7 등)
- `isScheduled(on:)`: 하루 진행률 모수 판정 — 수행일 아닌 daily 다짐은 그날 모수에서 제외
- **마감 시각(deadlineMinutes, 하루 단위 전용)**: 그날의 집계 창을 [하루 시작, 마감)으로 줄인다(`dayMeasurementRange` — 단일 구현, 주기 범위·span 하루 루프·연속 버킷팅·위젯 라벨·일기 카드가 공유). 마감 뒤 기록은 원본은 남되 그 다짐엔 미집계(시간형은 겹침 클리핑으로 부분 인정). 하루 시작보다 이른 시각=다음 달력일 새벽, 시작과 같은 시각=온전한 하루(0길이 창 없음, 검증 불필요). **의도된 트레이드오프**: '마감 지남' 전용 표시 상태 없음(게이지 값이 마감 시점에 연속이라 위젯 타임라인 마감 경계도 불필요), 연속 달성은 값 클리핑만 반영(오늘 마감을 놓쳐도 유예 규칙대로 끊김은 내일부터), ③ 위젯 누적 라벨은 창 안 값(`spanRawValue`). 주간/월간/기간 다짐 마감은 미지원(교차 span 의미가 정의 안 됨 — 확장 시 §4.2 전체 재설계 필요)
### 4.3 목표 진행률·판정 (Goal extension)
- `combinedSpanRatio(span)`: 소속 다짐 ratio 평균. 하루 span은 오늘이 수행일인 다짐만 모수(하나도 없으면 100% 취급). 모음 탭 카드·위젯·워치·시리가 공유
@ -180,7 +181,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
- 목표 행: 상태 뱃지(진행 중/달성/미달성/확인 필요) + 기간 진행률 바(종료일 있을 때). "확인 필요"는 탭해서 달성/미달성 직접 선택
- 목표 편집: 내용·아이콘·색·시작일(과거 가능)·종료일(선택)·**달성 판정 기준 슬라이더(10~100%, 5% 단위, 기본 100%)**
- 목표 상세: 다짐 목록(탭=수정, 스와이프 삭제, 순서 모드), 다짐 추가(무료 한도 3개), 종료일 없으면 수동 종료(기준<100% 안내 문구에 기준 표기), 삭제
- 다짐 편집 3단계: ①대상(행동 또는 꼬리표+측정 기준) ②주기(하루[매일/요일/날짜/n번째 요일]·주·월·특정 기간 — ordinalWeekday 구현은 weekdayOrdinal 즉 "그 달의 n번째 ◯요일"이며 UI 문구도 그렇게 표기) ③목표량(시간 휠 또는 횟수 스테퍼)+방향(이상 달성/이하 유지)
- 다짐 편집 3단계: ①대상(행동 또는 꼬리표+측정 기준) ②주기(하루[매일/요일/날짜/n번째 요일]·주·월·특정 기간 — ordinalWeekday 구현은 weekdayOrdinal 즉 "그 달의 n번째 ◯요일"이며 UI 문구도 그렇게 표기. 하루 단위는 '마감 시각까지만 집계' 토글+시간 선택 — §4.2 마감 규칙) ③목표량(시간 휠 또는 횟수 스테퍼)+방향(이상 달성/이하 유지)
- **연속 달성**(`QuestProgress.streak``QuestStreakInfo`): 하루 단위=적용일만 연속 계산("연속 N일" — 매주 수요일 다짐을 4주 연속이면 연속 4일), 주간/월간="연속 N주/N달", 특정 기간=nil. 진행 중인 오늘/이번 주기는 atLeast면 미달이어도 끊기지 않고(건너뜀) 채웠으면 포함, atMost는 넘는 순간 끊김. **하한 = 목표 시작일** — 행동이 아니라 "목표 안 다짐"의 연속이므로 목표 시작 전 날짜/주기는 세지 않음(주·월은 시작일이 걸친 부분 주기까지 포함). 특히 atMost는 기록 없는 날이 전부 달성이라 이 하한이 없으면 과거 전체가 연속으로 잡힘. 소급 상한 400일(성능). 기록을 하루 키로 1회 버킷팅해 O(기록+일수). 표기: 목표 탭 다짐 행 + 위젯 ②다짐 줄·③링 셀·④현황 그리드 (불꽃+노랑, 0이면 숨김). '스트릭'이라는 단어는 한국어 UI에 쓰지 않는다
- 목록 진입 시 `evaluateIfEnded` 일괄 실행
@ -309,7 +310,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f
## 14. DEBUG 런치 인자 (검증용, 모두 DEBUG 빌드 전용)
공통: `-seedDemo YES`(데모 데이터: 태그3·행동6·기록 7일치·목표7·자정 걸친 세션), `-seedBulk YES`(성능 검증용 과거 500일 약 1만 건 — 벌크 행동 4개), `-premium YES/NO`, `-startTab <main|action|tag|goal|history|stats|diary|settings>`, `-navStyle radial`, `-themeAutoToggle YES`
공통: `-seedDemo YES`(데모 데이터: 태그3·행동7·기록 7일치·목표8·자정 걸친 세션·마감 시각 다짐 1개[아침 루틴 08:00, 기대: 하루 100%·연속 1일]), `-seedBulk YES`(성능 검증용 과거 500일 약 1만 건 — 벌크 행동 4개), `-premium YES/NO`, `-startTab <main|action|tag|goal|history|stats|diary|settings>`, `-navStyle radial`, `-themeAutoToggle YES`
| 영역 | 인자 |
|---|---|

View File

@ -231,7 +231,7 @@ enum DebugSeed {
lines.append(
"goal=\(goal?.title ?? "-") start=\(goal?.startDate.formatted(date: .numeric, time: .omitted) ?? "-") "
+ "quest=\(quest.targetName) period=\(quest.period) direction=\(quest.direction) "
+ "streak=\(streak.map { "\($0.count) (\($0.unit))" } ?? "nil")"
+ "streak=\(streak.map { "\($0.count) (\($0.unit))" } ?? "nil") deadline=\(quest.deadlineMinutes)"
)
}
let url = URL.documentsDirectory.appending(path: "streak-dump.txt")
@ -439,8 +439,36 @@ enum DebugSeed {
screenQuest.direction = .atMost
context.insert(screenQuest)
// : " 8 1 "
// : 07:30( )+08:10( 100% 200% ),
// 08:30( ), 07:00( ).
// : 100%, 1( ), streak-dump deadline=480.
let wake = Action(name: "일찍 일어나기", symbolName: "sunrise.fill", trackingType: .count, sortOrder: 6)
wake.tags = [life]
context.insert(wake)
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 0, hour: 7, minute: 30)))
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 0, hour: 8, minute: 10)))
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 1, hour: 8, minute: 30)))
context.insert(CountEntry(action: wake, timestamp: at(daysAgo: 2, hour: 7, minute: 0)))
let morning = Goal(
title: "아침 루틴 지키기",
symbolName: "sunrise.fill",
colorHex: "#D9A621",
startDate: cal.date(byAdding: .day, value: -7, to: now)!,
endDate: nil
)
context.insert(morning)
let wakeQuest = Quest(goal: morning)
wakeQuest.targetAction = wake
wakeQuest.measure = .count
wakeQuest.period = .daily
wakeQuest.targetCount = 1
wakeQuest.direction = .atLeast
wakeQuest.deadlineMinutes = 8 * 60
context.insert(wakeQuest)
//
for (index, goal) in [toeic, health, habit, run, screen].enumerated() {
for (index, goal) in [toeic, health, habit, run, screen, morning].enumerated() {
goal.sortOrder = index
}
@ -453,7 +481,7 @@ enum DebugSeed {
endDate: cal.date(byAdding: .day, value: -30, to: now)!
)
sleep.status = .achieved
sleep.sortOrder = 5
sleep.sortOrder = 6
context.insert(sleep)
let diet = Goal(
title: "한 달 다이어트",
@ -463,7 +491,7 @@ enum DebugSeed {
endDate: cal.date(byAdding: .day, value: -20, to: now)!
)
diet.status = .notAchieved
diet.sortOrder = 6
diet.sortOrder = 7
context.insert(diet)
// UI : , (LocalPrefs )

File diff suppressed because it is too large Load Diff

View File

@ -1509,8 +1509,13 @@ private struct DiaryGoalsCard: View {
switch quest.period {
case .daily:
if progress.isActiveDay(dayKey) {
measured = dayRange
context = String(localized: "하루 목표")
// ( ~)
measured = progress.dayMeasurementRange(forKey: dayKey)
var label = String(localized: "하루 목표")
if let deadline = quest.deadlineLabel {
label += " · " + deadline
}
context = label
} else {
measured = nil
context = String(localized: "이 날은 수행일이 아니에요")

View File

@ -127,6 +127,11 @@ struct HelpView: View {
title: "진행률 읽는 법",
body: "다짐마다 하루·주간·월간 진행률이 표시돼요. 주간·월간 진행률은 하루 초과 달성이 다른 날의 미달을 가리지 않도록 하루 목표량까지만 인정해 합산해요. '이하 유지' 다짐은 한도를 넘지 않는 동안 100%로 표시돼요."
),
HelpTopic(
symbol: "clock.badge.checkmark",
title: "마감 시각까지 달성하기",
body: "하루 단위 다짐을 만들 때 '마감 시각까지만 집계'를 켜면 하루 시작부터 그 시각까지의 기록만 다짐에 집계돼요. 예를 들어 '일어나기 1회 · 오전 8:00까지'로 만들면 8시 이후에 누른 기록은 남긴 하지만 그날 달성으로는 인정되지 않아요. '이하 유지' 다짐은 마감 시각까지 한도를 지키면 그날 달성이에요. 시간 측정 행동이 마감을 걸치면 마감 전까지의 시간만 인정돼요. 하루 시작 시간보다 이른 시각을 고르면 다음 날 새벽(그 하루가 끝나기 전)을 뜻해요."
),
HelpTopic(
symbol: "flame.fill",
title: "연속 달성 표시",

View File

@ -45,6 +45,12 @@ struct QuestEditorView: View {
@State private var customStart: Date = .now
@State private var customEnd: Date = .now
// ( [ , ] )
@State private var hasDeadline = false
@State private var deadlineTime: Date = Calendar.current.date(
bySettingHour: 21, minute: 0, second: 0, of: .now
) ?? .now
// +
@State private var targetHours = 1
@State private var targetMinutes = 0
@ -121,7 +127,7 @@ struct QuestEditorView: View {
@ViewBuilder
private var periodSection: some View {
Section("② 주기 선택") {
Section {
Picker("반복 주기", selection: $period.animation()) {
ForEach(QuestPeriod.allCases) { p in
Text(p.label).tag(p)
@ -154,12 +160,23 @@ struct QuestEditorView: View {
}
}
}
// : [ , ] ( )
Toggle("마감 시각까지만 집계", isOn: $hasDeadline.animation())
if hasDeadline {
DatePicker("마감 시각", selection: $deadlineTime, displayedComponents: .hourAndMinute)
}
}
if period == .custom {
DatePicker("시작", selection: $customStart, displayedComponents: .date)
DatePicker("", selection: $customEnd, in: customStart..., displayedComponents: .date)
}
} header: {
Text("② 주기 선택")
} footer: {
if period == .daily && hasDeadline {
Text("하루 시작부터 마감 시각까지의 기록만 이 다짐에 집계돼요. 마감 이후의 기록은 기록 탭에 남지만 이 다짐에는 반영되지 않아요. 하루 시작 시간보다 이른 시각은 다음 날 새벽(그 하루가 끝나기 전)을 뜻해요.")
}
}
}
@ -287,6 +304,12 @@ struct QuestEditorView: View {
ordinalWeekday = quest.ordinalWeekday
customStart = quest.customStart ?? .now
customEnd = quest.customEnd ?? .now
if quest.deadlineMinutes >= 0 {
hasDeadline = true
let cal = Calendar.current
deadlineTime = cal.date(byAdding: .minute, value: quest.deadlineMinutes,
to: cal.startOfDay(for: .now)) ?? deadlineTime
}
targetHours = Int(quest.targetSeconds) / 3600
targetMinutes = (Int(quest.targetSeconds) % 3600) / 60
targetCount = quest.targetCount
@ -307,6 +330,13 @@ struct QuestEditorView: View {
}
target.period = period
target.scheduleMode = period == .daily ? scheduleMode : .everyDay
//
if period == .daily && hasDeadline {
let comps = Calendar.current.dateComponents([.hour, .minute], from: deadlineTime)
target.deadlineMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
} else {
target.deadlineMinutes = -1
}
target.weekdays = Array(weekdays).sorted()
target.monthDays = Array(monthDays).sorted()
target.ordinalWeek = ordinalWeek

View File

@ -384,6 +384,9 @@ final class Quest {
/// measure == .count
var targetCount: Int = 1
var directionRaw: String = QuestDirection.atLeast.rawValue
/// ( , -1 = CloudKit ).
/// [ , ] (QuestProgress.dayMeasurementRange)
var deadlineMinutes: Int = -1
///
var sortOrder: Int = 0
@ -449,8 +452,28 @@ extension Quest {
measure == .time ? Format.durationShort(targetSeconds) : String(localized: "\(targetCount)")
}
/// (: " ··")
/// .
/// ( ·) ,
/// ( ) .
var hasDeadline: Bool {
period == .daily && (0...1439).contains(deadlineMinutes)
}
/// (" 8:00"). nil
var deadlineLabel: String? {
guard hasDeadline else { return nil }
let cal = Calendar.current
let time = cal.date(byAdding: .minute, value: deadlineMinutes, to: cal.startOfDay(for: .now)) ?? .now
return String(localized: "\(Format.time(time))까지")
}
/// (: " ··", " · 8:00")
var scheduleLabel: String {
guard let deadlineLabel else { return baseScheduleLabel }
return "\(baseScheduleLabel) · \(deadlineLabel)"
}
private var baseScheduleLabel: String {
switch period {
case .daily:
switch scheduleMode {

View File

@ -90,6 +90,22 @@ struct QuestProgress {
math.dayKeys(in: range).filter(isActiveDay).count
}
/// " ". [ , ) .
/// (24)
/// ,
/// +24( ) 0 .
/// ( ) .
func dayMeasurementRange(forKey key: Date) -> Range<Date> {
let full = math.dayRange(forKey: key)
guard quest.hasDeadline else { return full }
let cal = math.calendar
var deadline = cal.date(byAdding: .minute, value: quest.deadlineMinutes, to: key) ?? full.upperBound
if deadline <= full.lowerBound {
deadline = cal.date(byAdding: .day, value: 1, to: deadline) ?? full.upperBound
}
return full.lowerBound..<min(deadline, full.upperBound)
}
/// ( ).
/// / , .
func isScheduled(on now: Date) -> Bool {
@ -111,7 +127,8 @@ struct QuestProgress {
case .daily:
let key = math.dayKey(for: now)
guard isActiveDay(key) else { return nil }
return math.dayRange(forKey: key)
// [ , ) current()·judgment
return dayMeasurementRange(forKey: key)
case .weekly:
return math.weekRange(containing: now)
case .monthly:
@ -203,25 +220,52 @@ struct QuestProgress {
/// - span: 200% .
/// - ' '(atMost): (100%/0%) ( ).
/// - // : .
/// - (deadlineMinutes) : span
/// ( ~) .
private func spanValue(for span: StatSpan, range: Range<Date>, now: Date) -> Double {
guard span != .day,
quest.period == .daily,
quest.direction == .atLeast,
quest.targetValue > 0
else {
// span: . () .
if span == .day {
guard quest.hasDeadline else { return value(in: range, now: now) }
return value(in: dayMeasurementRange(forKey: math.dayKey(for: range.lowerBound)), now: now)
}
// / span :
// - ' ' ( )
// - ()
// (// , ' ') .
let caps = quest.period == .daily && quest.direction == .atLeast && quest.targetValue > 0
guard caps || (quest.period == .daily && quest.hasDeadline) else {
return value(in: range, now: now)
}
let dailyTarget = quest.targetValue
let agg = Aggregator(math: math)
var sum = 0.0
for key in math.dayKeys(in: range) where isActiveDay(key) {
let dayRange = math.dayRange(forKey: key)
let dayRange = dayMeasurementRange(forKey: key)
let dayValue: Double
switch quest.measure {
case .time: dayValue = agg.seconds(for: quest.targetActions, in: dayRange, now: now)
case .count: dayValue = Double(agg.count(for: quest.targetActions, in: dayRange))
}
sum += min(dayValue, dailyTarget)
sum += caps ? min(dayValue, dailyTarget) : dayValue
}
return sum
}
/// span .
/// ( )· "" ,
/// ()
/// 0% . .
func spanRawValue(_ span: StatSpan, now: Date = .now) -> Double {
let range = spanRange(span, now: now)
guard quest.hasDeadline else { return value(in: range, now: now) }
let agg = Aggregator(math: math)
var sum = 0.0
for key in math.dayKeys(in: range) {
let dayRange = dayMeasurementRange(forKey: key)
switch quest.measure {
case .time: sum += agg.seconds(for: quest.targetActions, in: dayRange, now: now)
case .count: sum += Double(agg.count(for: quest.targetActions, in: dayRange))
}
}
return sum
}
@ -306,6 +350,16 @@ extension QuestProgress {
/// O( + ) .
private func perDayValues(since lowerBound: Date, now: Date) -> [Date: Double] {
var values: [Date: Double] = [:]
// () .
// . ( ).
let clips = quest.hasDeadline
var windowEnds: [Date: Date] = [:]
func windowEnd(_ key: Date) -> Date {
if let cached = windowEnds[key] { return cached }
let end = dayMeasurementRange(forKey: key).upperBound
windowEnds[key] = end
return end
}
for action in quest.targetActions {
switch quest.measure {
case .time:
@ -314,14 +368,22 @@ extension QuestProgress {
let start = max(session.startAt, lowerBound)
guard start < end else { continue }
for segment in math.splitByDay(start: start, end: end) {
var upper = segment.range.upperBound
if clips {
// ( )
upper = min(upper, windowEnd(segment.dayKey))
guard segment.range.lowerBound < upper else { continue }
}
values[segment.dayKey, default: 0]
+= segment.range.upperBound.timeIntervalSince(segment.range.lowerBound)
+= upper.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)
let key = math.dayKey(for: entry.timestamp)
if clips, entry.timestamp >= windowEnd(key) { continue }
values[key, default: 0] += Double(entry.amount)
}
}
}

View File

@ -251,6 +251,22 @@
}
}
},
"%@까지": {
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "By %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%@まで"
}
}
}
},
"%@은(는) 이미 측정 중이에요.": {
"localizations": {
"en": {
@ -3437,4 +3453,4 @@
}
},
"version": "1.0"
}
}

View File

@ -98,7 +98,7 @@ struct QuestCellSnapshot: Identifiable {
/// span ("1 20" / "3")
/// ( ' ') . ' '
/// . (· )
/// .
/// (, spanRawValue).
let valueLabel: String
var color: Color { Color(hex: colorHex) }
@ -109,7 +109,8 @@ struct QuestCellSnapshot: Identifiable {
let result = progress.spanProgress(span, now: now)
let streak = progress.streak(now: now)
let action = quest.targetAction
let rawValue = progress.value(in: progress.spanRange(span, now: now), now: now)
// ()
let rawValue = progress.spanRawValue(span, now: now)
let valueLabel: String
switch quest.measure {
case .time: