perf(1.5-p11): 등가 성능 최적화 — 건강 캐시 인메모리·백필 스위프·게이지 버킷화·위젯 타임라인, 빌드 9

소킹 중 '1.4 대비 버벅임 증가' 보고의 원인 수정 (동작·수치 완전 불변):
- HealthCache/HealthIntervals 인메모리 캐시(세대 카운터 무효화) + workoutKeys 이중 load 제거
- DayMath.calendar 저장 프로퍼티화 (접근마다 Calendar 사본 생성 제거)
- 수면 백필 O(400일×샘플수) → 정렬+포인터 스위프 (퍼즈 3,000회 등가 증명)
- refreshRecent 캐시 기록 배칭 (일수×2회 → 총 2회)
- QuestProgress.spanValue/spanRawValue 하루 버킷화 (게이지의 미래 기록 즉시 반영 스펙 보존)
- 위젯 라이브 타임라인 makeEntry refresh 파라미터 (엔트리 7개×컨테이너 오픈 → 1회)
- 일기 건강 카드 칩 value() 이중 호출 제거

검증: 옛/새 빌드 A/B — 덤프 3종 값 동일·4화면 픽셀 동일, 자가 검증 62/20/27/10
ALL PASS(26.5+18.5), 벌크 1만 건 렌더 정상, 3종 빌드 그린, 카탈로그 무변경.
whats-new-1.5 3언어에 성능 개선 줄 추가.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WtZwRRjbtM9pZJDM4FkTq6
This commit is contained in:
songyc macbook 2026-08-26 14:23:56 +09:00
parent 5ce9dfdd6d
commit 2ae469d187
12 changed files with 247 additions and 55 deletions

File diff suppressed because one or more lines are too long

View File

@ -191,3 +191,11 @@
- 시드: -seedHealthQuest 걸음 다짐에 #3E7CB1 시드(신규·멱등 재실행 모두 — 렌더 검증+스키마 내보내기 트리거 겸용). 도움말·whats-new 3언어 갱신, l10n 0/0.
- 후속(빌드 8): 위젯·워치 스냅숏이 targetColor 미경유(자체 폴백 — 위젯 분홍 하드코딩, 워치는 초록 폴백[1.5 잠복 표시 버그]) → healthColorHex 우선·기본 #E0558C로 통일. ④ 그리드 실측(걸음 파랑/기본 분홍). 스키마 무변경 — 콘솔 작업 불필요.
### §12 후속 4 — 성능 최적화 (2026-08-26, 빌드 9)
- **발단(사용자 소킹 보고)**: 1.5가 1.4 대비 전반적 버벅임 증가(아이폰 15PM·아이패드 3세대 공통, 기능 이상 없음). 원칙: **동작·수치·안정성 완전 불변 — 등가 최적화만** 적용(관찰 가능한 동작이 변하는 항목은 제외).
- **진단(코드 전수 정독)**: 몸통은 건강 캐시 읽기 방식 — `HealthCache.value()`가 호출마다 전체 딕셔너리(지표 10여 종×400일 ≈ 수천 엔트리)를 defaults에서 통째로 브리징. 건강 다짐 진행률이 "하루 값×일수" 구조라 다짐 행 하나 렌더에 ~38회+, 워치 스냅숏(커밋마다·메인 스레드)에 다짐당 ~76회, 여기에 ②수면 백필 O(400일×전체 수면 샘플)의 하루 1회 메인 정지 ③위젯 라이브 타임라인의 엔트리 7개×새 컨테이너 오픈 ④DayMath.calendar 접근마다 Calendar 사본 생성(전역 곱셈) ⑤spanValue의 O(일수×기록수)(1.4부터 있던 것 — 데이터 증가로 악화)가 겹친 그림.
- **적용 7건**: ⓐ건강 캐시 2종 인메모리화(세대 카운터로 프로세스 간 무효화, workoutKeys 이중 load 제거) ⓑDayMath.calendar 저장 프로퍼티화 ⓒ수면 백필 정렬+포인터 스위프 ⓓrefreshRecent 기록 배칭 ⓔspanValue/spanRawValue 하루 버킷화(**게이지의 '미래 기록 즉시 반영' 스펙 보존** — streak의 perDayValues와 별도 헬퍼 perDayGaugeValues/perDayRecordValues) ⓕ위젯 4프로바이더 makeEntry refresh 파라미터(타임라인당 컨테이너 1회) ⓖ일기 건강 카드 칩 value() 1회화.
- **의도적 제외**: 건강 재조회 스로틀(신선도 변화=동작 변화), 워치 스냅숏 백그라운드화(SwiftData 메인 컨텍스트 위험), 모음 탭 셀 오늘 값 버킷화(구조 변경 — 소킹 체감 보고 후 재논의).
- **검증(엄밀 A/B 포함)**: ①옛/새 빌드를 각각 **새 컨테이너+동일 시드**(seedDemo+건강 캐시+건강 다짐+주기 몫)로 캡처 — streak·건강 다짐·인텐트 스모크 덤프 3종 **값 완전 동일**(순서 무관 정렬 diff 0 — fetch 순서 비결정성만 존재), 목표 탭·모음 탭·위젯 ④·일기 4화면 **픽셀 단위 동일**(자작 pixdiff — 상태바 시계·시뮬 iCloud 배너만 제외) ②자가 검증 62/20/27/10 ALL PASS(26.5 심+**18.5 심 62건 별도 ALL PASS**) ③수면 스위프는 무작위 퍼즈 **3,000회 등가 증명**(창 40/400/7개×길이 1분~24h×DST식 겹침 창×구간 0~2,000개×경계 정확 접촉·0길이·전체 덮개) ④벌크 1만 건 시드에서 목표·통계·모음 3탭 렌더 정상(수치 실측 일치) ⑤3종 빌드(Debug·Store·워치) 그린 ⑥신규 현지화 문자열 0(카탈로그 무변경). 스토어: whats-new-1.5 3언어에 성능 개선 1줄 추가.

View File

@ -601,7 +601,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -643,7 +643,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = IOS/Haru_Danim.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -686,7 +686,7 @@
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -722,7 +722,7 @@
CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -758,7 +758,7 @@
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
@ -794,7 +794,7 @@
CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist;
@ -827,7 +827,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Widgets/Info.plist;
@ -853,7 +853,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = Widgets/Haru_DanimWidgets.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 8;
CURRENT_PROJECT_VERSION = 9;
DEVELOPMENT_TEAM = 5GNS4Z2SYT;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Widgets/Info.plist;

View File

@ -147,20 +147,32 @@ final class HealthDataStore {
let cal = math.calendar
let todayKey = math.dayKey(for: now)
let keys = questHealthKeys()
// 1 (defaults )
// 2 × 2 . (1.5(9) )
var cacheBatch: [String: [Date: Double]] = [:]
var intervalBatch: [String: [Date: [[Double]]]] = [:]
for offset in 0..<max(1, days) {
guard let key = cal.date(byAdding: .day, value: -offset, to: todayKey) else { continue }
let values = await allValues(
forDayKey: key, math: math,
sleepWindows: keys.sleepWindows, workoutKinds: keys.workoutKinds
)
HealthCache.setValues(values, dayKey: key)
HealthIntervals.merge(await dayIntervals(forDayKey: key, math: math))
for (metricRaw, value) in values {
cacheBatch[metricRaw, default: [:]][key] = value
}
for (intervalKey, dayLists) in await dayIntervals(forDayKey: key, math: math) {
// (dayKey)
//
intervalBatch[intervalKey, default: [:]].merge(dayLists) { _, new in new }
}
if offset == 0 {
var tiles: [HealthMetric: Double] = [:]
for metric in HealthMetric.allCases { tiles[metric] = values[metric.rawValue] ?? 0 }
todayValues = tiles
}
}
HealthCache.merge(cacheBatch)
HealthIntervals.merge(intervalBatch)
}
/// ( )· .
@ -480,27 +492,42 @@ final class HealthDataStore {
}
merged[HealthMetric.mindfulMinutes.rawValue] = mindfulDays
// ( )
// ( ).
// 1.5(9) : (400) (
// ) O(×) .
// 1 .
// lower·upper start
// (hi), end
// (lo) .
// ce > cs .
let sleepSamples = await categorySamples(
.sleepAnalysis, in: lower.addingTimeInterval(-24 * 3600)..<upper
)
let asleepValues = Set(HKCategoryValueSleepAnalysis.allAsleepValues.map(\.rawValue))
let asleepIntervals: [(Date, Date)] = sleepSamples.compactMap { sample in
asleepValues.contains(sample.value) ? (sample.startDate, sample.endDate) : nil
}
let asleepIntervals: [(Date, Date)] = sleepSamples
.compactMap { sample in
asleepValues.contains(sample.value) ? (sample.startDate, sample.endDate) : nil
}
.sorted { $0.0 < $1.0 }
for spec in keys.sleepWindows {
guard let parsed = Self.windowSpec(spec) else { continue }
var days: [Date: Double] = [:]
var key = startKey
var lo = 0
var hi = 0
while key <= todayKey {
let window = Self.sleepWindow(
forDayRange: math.dayRange(forKey: key),
endMinutes: parsed.endMinutes, length: parsed.length
)
let clipped: [(Date, Date)] = asleepIntervals.compactMap { start, end in
while hi < asleepIntervals.count, asleepIntervals[hi].0 < window.upperBound { hi += 1 }
while lo < hi, asleepIntervals[lo].1 <= window.lowerBound { lo += 1 }
var clipped: [(Date, Date)] = []
for index in lo..<hi {
let (start, end) = asleepIntervals[index]
let cs = max(start, window.lowerBound)
let ce = min(end, window.upperBound)
return ce > cs ? (cs, ce) : nil
if ce > cs { clipped.append((cs, ce)) }
}
let value = Self.mergedDuration(clipped)
if value > 0 { days[key] = value }

View File

@ -499,7 +499,9 @@ struct DiaryHealthCard: View {
}
private func chip(_ metric: HealthMetric) -> some View {
VStack(alignment: .leading, spacing: 3) {
// 1 · value() (1.5(9))
let value = value(metric)
return VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
Image(safeSymbol: metric.symbolName)
.font(.system(size: 11, weight: .semibold))
@ -509,7 +511,7 @@ struct DiaryHealthCard: View {
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let value = value(metric) {
if let value {
// ja "728" (1.5(4) )
Text(metric.valueLabel(value))
.font(.system(.subheadline, design: .rounded).weight(.bold).monospacedDigit())
@ -526,7 +528,7 @@ struct DiaryHealthCard: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(verbatim: "\(metric.name) \(value(metric).map(metric.valueLabel) ?? String(localized: "기록 없음"))"))
.accessibilityLabel(Text(verbatim: "\(metric.name) \(value.map(metric.valueLabel) ?? String(localized: "기록 없음"))"))
}
}

View File

@ -18,6 +18,7 @@
• 측정 중엔 노란 타이머로 바뀌고, 누르면 워치 앱에서 바로 시작·종료할 수 있어요
● 그리고
• 앱 곳곳의 반응 속도를 개선했어요 — 목표·다짐 화면과 위젯이 한결 가벼워져요
• 도움말 전체 문구를 더 자연스럽게 다듬고 새 기능 사용법을 더했어요
• 여러 기기를 쓰신다면 모든 기기에서 함께 업데이트해 주세요
@ -39,6 +40,7 @@ This update brings Haru Danim closer to Apple Health — and your Apple Watch. A
• While tracking it turns into a yellow timer — tap to start or stop from the watch app
● And more
• Performance improvements across the app — goals, quests, and widgets feel noticeably smoother
• The entire in-app guide has been rewritten to read more naturally, with how-tos for the new features
• Using several devices? Please update Haru Danim on all of them
@ -60,5 +62,6 @@ This update brings Haru Danim closer to Apple Health — and your Apple Watch. A
• 計測中は黄色いタイマーに変わり、タップするとウォッチアプリからすぐ開始・終了できます
● そのほか
• アプリ全体の反応速度を改善しました — 目標・クエスト画面やウィジェットがより軽快になります
• アプリ内ヘルプの文章全体をより自然に磨き直し、新機能の使い方も加えました
• 複数の端末をお使いなら、すべての端末で一緒にアップデートしてください

View File

@ -10,13 +10,17 @@ import Foundation
/// ( ), / ,
struct DayMath {
let settings: TrackingSettings
/// 1 (1.5(9) ).
/// Calendar.current +firstWeekday
/// , dayKey 3·dayKeys/splitByDay ×
/// . settings .
let calendar: Calendar
init(settings: TrackingSettings = .current()) {
self.settings = settings
self.calendar = settings.calendar
}
var calendar: Calendar { settings.calendar }
// MARK:
/// `date` ( Date).

View File

@ -221,8 +221,51 @@ nonisolated enum HealthCache {
return "\(y)\(mm)\(dd)"
}
// MARK: (1.5(9) · )
//
// value() defaults.dictionary ( 10 × 400
// ) Swift . " ×
// " ~30, ~38+,
// ~76 1.5 .
// , ( ) defaults
// ( ) ( double 1 )
// .
private static let generationKey = "health.cache.gen"
private static let cacheLock = NSLock()
nonisolated(unsafe) private static var cached: [String: [String: Double]]?
nonisolated(unsafe) private static var cachedGeneration: Double = -1
private static func load() -> [String: [String: Double]] {
(AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: Double]]) ?? [:]
let generation = AppGroup.defaults.double(forKey: generationKey)
cacheLock.lock()
if let cached, cachedGeneration == generation {
let result = cached
cacheLock.unlock()
return result
}
cacheLock.unlock()
let fresh = (AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: Double]]) ?? [:]
cacheLock.lock()
cached = fresh
cachedGeneration = generation
cacheLock.unlock()
return fresh
}
/// + + ( 3 ).
///
private static func store(_ all: [String: [String: Double]]?) {
if let all {
AppGroup.defaults.set(all, forKey: storageKey)
} else {
AppGroup.defaults.removeObject(forKey: storageKey)
}
let generation = AppGroup.defaults.double(forKey: generationKey) + 1
AppGroup.defaults.set(generation, forKey: generationKey)
cacheLock.lock()
cached = all ?? [:]
cachedGeneration = generation
cacheLock.unlock()
}
static func value(_ metricRaw: String, dayKey: Date) -> Double? {
@ -243,7 +286,7 @@ nonisolated enum HealthCache {
}
all[metricRaw] = days
}
AppGroup.defaults.set(all, forKey: storageKey)
store(all)
}
/// × ( [: [: ]])
@ -261,12 +304,12 @@ nonisolated enum HealthCache {
}
all[metricRaw] = stored
}
AppGroup.defaults.set(all, forKey: storageKey)
store(all)
}
/// ( )
static func clearAll() {
AppGroup.defaults.removeObject(forKey: storageKey)
store(nil)
HealthIntervals.clearAll()
}
}
@ -285,8 +328,42 @@ nonisolated enum HealthIntervals {
static let sleepKey = "sleep"
static func workoutKey(_ kind: HealthWorkoutKind) -> String { "workout.\(kind.rawValue)" }
// HealthCache (1.5(9) , · ).
// 3~5, ×7
private static let generationKey = "health.intervals.gen"
private static let cacheLock = NSLock()
nonisolated(unsafe) private static var cached: [String: [String: [[Double]]]]?
nonisolated(unsafe) private static var cachedGeneration: Double = -1
private static func load() -> [String: [String: [[Double]]]] {
(AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: [[Double]]]]) ?? [:]
let generation = AppGroup.defaults.double(forKey: generationKey)
cacheLock.lock()
if let cached, cachedGeneration == generation {
let result = cached
cacheLock.unlock()
return result
}
cacheLock.unlock()
let fresh = (AppGroup.defaults.dictionary(forKey: storageKey) as? [String: [String: [[Double]]]]) ?? [:]
cacheLock.lock()
cached = fresh
cachedGeneration = generation
cacheLock.unlock()
return fresh
}
private static func store(_ all: [String: [String: [[Double]]]]?) {
if let all {
AppGroup.defaults.set(all, forKey: storageKey)
} else {
AppGroup.defaults.removeObject(forKey: storageKey)
}
let generation = AppGroup.defaults.double(forKey: generationKey) + 1
AppGroup.defaults.set(generation, forKey: generationKey)
cacheLock.lock()
cached = all ?? [:]
cachedGeneration = generation
cacheLock.unlock()
}
/// · ( )
@ -301,7 +378,9 @@ nonisolated enum HealthIntervals {
/// ( )
static func workoutKeys(dayKey: Date) -> [String] {
let token = HealthCache.dayToken(dayKey)
return load().keys.filter { $0.hasPrefix("workout.") && !(load()[$0]?[token] ?? []).isEmpty }
// load() 1 (1.5(9))
let all = load()
return all.keys.filter { $0.hasPrefix("workout.") && !(all[$0]?[token] ?? []).isEmpty }
}
/// × (· ).
@ -325,11 +404,11 @@ nonisolated enum HealthIntervals {
}
all[key] = stored
}
AppGroup.defaults.set(all, forKey: storageKey)
store(all)
}
static func clearAll() {
AppGroup.defaults.removeObject(forKey: storageKey)
store(nil)
}
}

View File

@ -263,16 +263,85 @@ struct QuestProgress {
}
let caps = quest.direction == .atLeast && quest.targetValue > 0
let dailyTarget = quest.targetValue
// 1.5(9) : value(in:) O(×)
// (perDayGaugeValues 1 , ) .
// ·
let daily = perDayGaugeValues(in: range, now: now)
var sum = 0.0
for key in math.dayKeys(in: range) where isActiveDay(key) {
// value(in:) Aggregator
// , (1.5)
let dayValue = value(in: dayMeasurementRange(forKey: key), now: now)
let dayValue = daily[key] ?? 0
sum += caps ? min(dayValue, dailyTarget) : dayValue
}
return sum
}
/// span " value(in: dayMeasurementRange)"
/// ( · · now)
/// (1.5(9) O(×) O(+)).
/// perDayValues : now
/// (§6.4 ), " "
/// value(in:) (Aggregator) .
private func perDayGaugeValues(in range: Range<Date>, now: Date) -> [Date: Double] {
// : (
// dayMeasurementRange )
if let health = quest.healthTarget {
var values: [Date: Double] = [:]
for key in math.dayKeys(in: range) {
if let value = HealthQuestValues.dayValue(target: health, quest: quest, dayKey: key) {
values[key] = value
}
}
return values
}
return perDayRecordValues(in: range, now: now)
}
/// (·) perDayGaugeValues .
/// spanRawValue( ) : Aggregator
/// ( targetActions 0)
private func perDayRecordValues(in range: Range<Date>, now: Date) -> [Date: Double] {
var values: [Date: Double] = [:]
// perDayValues( )
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:
for session in action.sessions {
// now
// (Aggregator.seconds `session.endAt ?? now` )
let end = session.endAt ?? now
let start = max(session.startAt, range.lowerBound)
let clippedEnd = min(end, range.upperBound)
guard start < clippedEnd else { continue }
for segment in math.splitByDay(start: start, end: clippedEnd) {
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]
+= upper.timeIntervalSince(segment.range.lowerBound)
}
}
case .count:
for entry in action.countEntries where range.contains(entry.timestamp) {
let key = math.dayKey(for: entry.timestamp)
if clips, entry.timestamp >= windowEnd(key) { continue }
values[key, default: 0] += Double(entry.amount)
}
}
}
return values
}
/// span .
/// ( )· "" ,
/// ()
@ -280,16 +349,10 @@ struct QuestProgress {
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
// 1.5(9) : Aggregator 1.
// (perDayRecordValues )
let daily = perDayRecordValues(in: range, now: now)
return math.dayKeys(in: range).reduce(0) { $0 + (daily[$1] ?? 0) }
}
// MARK: (§4.2)

View File

@ -92,8 +92,10 @@ struct GoalProgressProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: GoalProgressConfigIntent, family: WidgetFamily,
now: Date = .now) -> GoalProgressEntry {
IntentStore.refresh()
now: Date = .now, refresh: Bool = true) -> GoalProgressEntry {
// ( )
// 7 (1.5(9) )
if refresh { IntentStore.refresh() }
guard WidgetStore.isUnlocked else {
return GoalProgressEntry(date: now, locked: true, theme: configuration.theme,
mediumStyle: configuration.mediumStyle,
@ -125,7 +127,7 @@ struct GoalProgressProvider: AppIntentTimelineProvider {
func timeline(for configuration: GoalProgressConfigIntent, in context: Context) async -> Timeline<GoalProgressEntry> {
let first = Self.makeEntry(configuration, family: context.family)
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
Self.makeEntry(configuration, family: context.family, now: date)
Self.makeEntry(configuration, family: context.family, now: date, refresh: false)
}
}
}
@ -458,8 +460,9 @@ struct QuestStatusProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: QuestStatusConfigIntent, family: WidgetFamily,
now: Date = .now) -> QuestStatusEntry {
IntentStore.refresh()
now: Date = .now, refresh: Bool = true) -> QuestStatusEntry {
// (GoalProgressProvider )
if refresh { IntentStore.refresh() }
guard WidgetStore.isUnlocked else {
return QuestStatusEntry(date: now, locked: true, theme: configuration.theme,
spanLabel: "", largeStyle: configuration.largeStyle, goals: [])
@ -492,7 +495,7 @@ struct QuestStatusProvider: AppIntentTimelineProvider {
func timeline(for configuration: QuestStatusConfigIntent, in context: Context) async -> Timeline<QuestStatusEntry> {
let first = Self.makeEntry(configuration, family: context.family)
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
Self.makeEntry(configuration, family: context.family, now: date)
Self.makeEntry(configuration, family: context.family, now: date, refresh: false)
}
}
}

View File

@ -60,8 +60,10 @@ struct LockGoalEntry: TimelineEntry {
struct LockGoalProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: LockGoalConfigIntent, now: Date = .now) -> LockGoalEntry {
IntentStore.refresh()
static func makeEntry(_ configuration: LockGoalConfigIntent, now: Date = .now,
refresh: Bool = true) -> LockGoalEntry {
// (GoalProgressProvider )
if refresh { IntentStore.refresh() }
guard PremiumGate.isUnlocked(.lockScreenWidgets) else {
return LockGoalEntry(date: now, locked: true, spanLabel: "", style: .gauge, goal: nil, ratio: 0)
}
@ -92,7 +94,7 @@ struct LockGoalProvider: AppIntentTimelineProvider {
func timeline(for configuration: LockGoalConfigIntent, in context: Context) async -> Timeline<LockGoalEntry> {
let first = Self.makeEntry(configuration)
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
Self.makeEntry(configuration, now: date)
Self.makeEntry(configuration, now: date, refresh: false)
}
}
}

View File

@ -56,8 +56,9 @@ struct QuestRingProvider: AppIntentTimelineProvider {
@MainActor
static func makeEntry(_ configuration: QuestRingConfigIntent, family: WidgetFamily,
now: Date = .now) -> QuestRingEntry {
IntentStore.refresh()
now: Date = .now, refresh: Bool = true) -> QuestRingEntry {
// (GoalProgressProvider )
if refresh { IntentStore.refresh() }
guard WidgetStore.isUnlocked else {
return QuestRingEntry(date: now, locked: true, theme: configuration.theme, spanLabel: "", cells: [])
}
@ -88,7 +89,7 @@ struct QuestRingProvider: AppIntentTimelineProvider {
func timeline(for configuration: QuestRingConfigIntent, in context: Context) async -> Timeline<QuestRingEntry> {
let first = Self.makeEntry(configuration, family: context.family)
return WidgetRefresh.timeline(first: first, live: first.isLive) { date in
Self.makeEntry(configuration, family: context.family, now: date)
Self.makeEntry(configuration, family: context.family, now: date, refresh: false)
}
}
}