Files
kangkang/康康/Features/Trends/SeriesBucket.swift
link2026 c47e62b658 fix(concurrency): 三个 enum 标 nonisolated
工程开了 SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor,这三个纯值类型
enum 会被推成 MainActor 隔离,跨 actor 使用时报错:

- CustomMetricNameConflict (CustomMetricEditor)
- SeriesKind (SeriesBucket)
- ExportTrendBuilder.Direction

已随 v1.1 (build 6) 一同编译上传。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 20:11:03 +08:00

256 lines
9.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import SwiftData
import Foundation
///
/// - `.monitor`: / / ( seriesKey )
/// - `.lab`: 2 //( name+unit , seriesKey)
nonisolated enum SeriesKind { case monitor, lab }
/// Trends 线
/// (//...)= 1 SeriesLine; = + 2 线
struct SeriesBucket: Identifiable {
let id: String
let title: String
let unit: String
let lines: [SeriesLine]
let latestDate: Date
let kind: SeriesKind
struct SeriesLine: Identifiable {
let id: String
let seriesKey: String
let label: String?
let color: Color
let points: [Point]
let referenceRange: ClosedRange<Double>?
var latestPoint: Point? { points.last }
}
struct Point: Identifiable, Hashable {
let id: String
let date: Date
let value: Double
let status: IndicatorStatus
}
}
extension SeriesBucket {
/// Indicator( seriesKey ) SeriesBucket
/// seriesKey capturedAt ;BP key bucket;
/// `minPoints` , 2(线)
static func build(from indicators: [Indicator],
profile: UserProfile? = nil,
customMetrics: [CustomMonitorMetric] = [],
minPoints: Int = 2) -> [SeriesBucket] {
var buckets: [String: [Indicator]] = [:]
for i in indicators {
guard let key = i.seriesKey, !key.isEmpty else { continue }
buckets[key, default: []].append(i)
}
//
let bpKeys: Set<String> = ["bp.systolic", "bp.diastolic"]
let bpHasEnoughPoints = (buckets["bp.systolic"]?.count ?? 0) >= minPoints
var results: [SeriesBucket] = []
if bpHasEnoughPoints {
results.append(buildBP(from: buckets, profile: profile))
}
for k in bpKeys { buckets.removeValue(forKey: k) }
let customByKey: [String: CustomMonitorMetric] = Dictionary(
uniqueKeysWithValues: customMetrics.map { ($0.seriesKey, $0) }
)
for (key, items) in buckets {
guard items.count >= minPoints else { continue }
if let bucket = buildSingle(key: key, items: items,
profile: profile,
custom: customByKey[key]) {
results.append(bucket)
}
}
// lab : seriesKey , name+unit ; minPoints
var labBuckets: [String: [Indicator]] = [:]
for i in indicators {
if let key = i.seriesKey, !key.isEmpty { continue } // seriesKey monitor
let nk = normalizedKey(name: i.name, unit: i.unit)
guard !nk.isEmpty else { continue }
labBuckets[nk, default: []].append(i)
}
for (_, items) in labBuckets {
guard items.count >= minPoints else { continue }
if let bucket = buildLab(items: items) {
results.append(bucket)
}
}
return results.sorted { $0.latestDate > $1.latestDate }
}
/// name+unit :trim + + ()
static func normalizedKey(name: String, unit: String) -> String {
func norm(_ s: String) -> String {
s.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: " ")
}
let n = norm(name)
guard !n.isEmpty else { return "" }
return n + "|" + norm(unit)
}
/// ClosedRange "3.9-6.1" / "3.9~6.1" / "3.9 - 6.1"
/// ("<5.2" / ">40" / "120") nil(,)
static func parseRange(_ raw: String) -> ClosedRange<Double>? {
let s = raw.replacingOccurrences(of: "", with: "~")
.replacingOccurrences(of: "~", with: "-")
guard let regex = try? NSRegularExpression(
pattern: #"(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)"#
) else { return nil }
let range = NSRange(s.startIndex..<s.endIndex, in: s)
guard let m = regex.firstMatch(in: s, range: range),
let r1 = Range(m.range(at: 1), in: s),
let r2 = Range(m.range(at: 2), in: s),
let lo = Double(s[r1]), let hi = Double(s[r2]),
lo <= hi else { return nil }
return lo...hi
}
private static func buildLab(items: [Indicator]) -> SeriesBucket? {
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
guard let latest = sorted.last else { return nil }
let points = sorted.compactMap { point(from: $0) }
guard points.count >= 2 else { return nil } // , 2
let line = SeriesLine(
id: "lab:\(latest.name)",
seriesKey: "lab:\(latest.name)",
label: nil,
color: Tj.Palette.teal,
points: points,
referenceRange: parseRange(latest.range)
)
return SeriesBucket(
id: "lab:\(normalizedKey(name: latest.name, unit: latest.unit))",
title: latest.name,
unit: latest.unit,
lines: [line],
latestDate: latest.capturedAt,
kind: .lab
)
}
private static func buildSingle(key: String,
items: [Indicator],
profile: UserProfile?,
custom: CustomMonitorMetric? = nil) -> SeriesBucket? {
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
guard let latest = sorted.last else { return nil }
// custom, builtin metric, fallback Indicator
let metric = monitorMetric(for: key)
let field = metric?.fields.first { $0.seriesKey == key }
let title = custom?.name
?? metric?.displayName
?? sorted.first?.name
?? key
let unit = custom?.unit.nonEmptyOr(nil)
?? field?.unit
?? sorted.first?.unit
?? ""
let range = custom?.referenceRange
?? field.flatMap { metric?.effectiveRange(for: $0, profile: profile) }
let line = SeriesLine(
id: key,
seriesKey: key,
label: nil,
color: Tj.Palette.teal,
points: sorted.compactMap { point(from: $0) },
referenceRange: range
)
return SeriesBucket(
id: key,
title: title,
unit: unit,
lines: [line],
latestDate: latest.capturedAt,
kind: .monitor
)
}
private static func buildBP(from buckets: [String: [Indicator]],
profile: UserProfile?) -> SeriesBucket {
let m = MonitorMetric.bloodPressure
let sysField = m.fields[0]
let diaField = m.fields[1]
let sysItems = (buckets["bp.systolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt }
let diaItems = (buckets["bp.diastolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt }
let sysLine = SeriesLine(
id: "bp.systolic",
seriesKey: "bp.systolic",
label: String(appLoc: "收缩"),
color: Tj.Palette.teal,
points: sysItems.compactMap { point(from: $0) },
referenceRange: m.effectiveRange(for: sysField, profile: profile)
)
let diaLine = SeriesLine(
id: "bp.diastolic",
seriesKey: "bp.diastolic",
label: String(appLoc: "舒张"),
color: Tj.Palette.leaf,
points: diaItems.compactMap { point(from: $0) },
referenceRange: m.effectiveRange(for: diaField, profile: profile)
)
let latest = max(
sysItems.last?.capturedAt ?? .distantPast,
diaItems.last?.capturedAt ?? .distantPast
)
// 线:(/)线 +
let lines = [sysLine, diaLine].filter { !$0.points.isEmpty }
return SeriesBucket(
id: "bp",
title: String(appLoc: "血压"),
unit: "mmHg",
lines: lines,
latestDate: latest,
kind: .monitor
)
}
private static func point(from i: Indicator) -> Point? {
guard let v = Double(i.value.trimmingCharacters(in: .whitespaces)) else { return nil }
return Point(
id: "\(i.persistentModelID)",
date: i.capturedAt,
value: v,
status: i.status
)
}
private static func monitorMetric(for seriesKey: String) -> MonitorMetric? {
MonitorMetric.allCases.first { m in
m.fields.contains { $0.seriesKey == seriesKey }
}
}
}
private extension String {
/// fallback;
func nonEmptyOr(_ fallback: String?) -> String? {
trimmingCharacters(in: .whitespaces).isEmpty ? fallback : self
}
}