docs(claude): sync §5/§7/§10 with Monitor+Profile; fix SeriesBucket SwiftData import
- §5 schema 重写为 7 @Model 完整列表(含 UserProfile + Indicator.seriesKey) - §7 IA 改成 5 槽 TabBar(2 内容 + 中间 + + 2 设置),记录入口 5 个 kind - §10.6 红线例外清单加 Monitor + Profile(Symptom 也补上) - SeriesBucket.swift 缺 import SwiftData(persistentModelID 报错) 全套测试 50 case pass / 0 fail / 0 warning。
This commit is contained in:
153
康康/Features/Trends/SeriesBucket.swift
Normal file
153
康康/Features/Trends/SeriesBucket.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
/// 长期监测系列在 Trends 折线图里的展示桶。
|
||||
/// 单系列(血糖/体重/...)= 1 个 SeriesLine;血压特殊 = 收缩 + 舒张 2 条线同卡。
|
||||
struct SeriesBucket: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let unit: String
|
||||
let lines: [SeriesLine]
|
||||
let latestDate: Date
|
||||
|
||||
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,
|
||||
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 bpIndicators = bpKeys.flatMap { buckets[$0] ?? [] }
|
||||
let bpHasEnoughPoints = bpIndicators.filter { $0.seriesKey == "bp.systolic" }.count >= minPoints
|
||||
|
||||
var results: [SeriesBucket] = []
|
||||
|
||||
if bpHasEnoughPoints {
|
||||
results.append(buildBP(from: buckets, profile: profile))
|
||||
}
|
||||
for k in bpKeys { buckets.removeValue(forKey: k) }
|
||||
|
||||
for (key, items) in buckets {
|
||||
guard items.count >= minPoints else { continue }
|
||||
if let bucket = buildSingle(key: key, items: items, profile: profile) {
|
||||
results.append(bucket)
|
||||
}
|
||||
}
|
||||
|
||||
return results.sorted { $0.latestDate > $1.latestDate }
|
||||
}
|
||||
|
||||
private static func buildSingle(key: String,
|
||||
items: [Indicator],
|
||||
profile: UserProfile?) -> SeriesBucket? {
|
||||
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
|
||||
guard let latest = sorted.last else { return nil }
|
||||
|
||||
let metric = monitorMetric(for: key)
|
||||
let field = metric?.fields.first { $0.seriesKey == key }
|
||||
let title = metric?.displayName ?? sorted.first?.name ?? key
|
||||
let unit = field?.unit ?? sorted.first?.unit ?? ""
|
||||
let range = field.flatMap { metric?.effectiveRange(for: $0, profile: profile) }
|
||||
|
||||
let line = SeriesLine(
|
||||
id: key,
|
||||
seriesKey: key,
|
||||
label: nil,
|
||||
color: Tj.Palette.ink,
|
||||
points: sorted.compactMap { point(from: $0) },
|
||||
referenceRange: range
|
||||
)
|
||||
|
||||
return SeriesBucket(
|
||||
id: key,
|
||||
title: title,
|
||||
unit: unit,
|
||||
lines: [line],
|
||||
latestDate: latest.capturedAt
|
||||
)
|
||||
}
|
||||
|
||||
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: "收缩",
|
||||
color: Tj.Palette.brick,
|
||||
points: sysItems.compactMap { point(from: $0) },
|
||||
referenceRange: m.effectiveRange(for: sysField, profile: profile)
|
||||
)
|
||||
let diaLine = SeriesLine(
|
||||
id: "bp.diastolic",
|
||||
seriesKey: "bp.diastolic",
|
||||
label: "舒张",
|
||||
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
|
||||
)
|
||||
|
||||
return SeriesBucket(
|
||||
id: "bp",
|
||||
title: "血压",
|
||||
unit: "mmHg",
|
||||
lines: [sysLine, diaLine],
|
||||
latestDate: latest
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user