From 9a6d21100bddd8f602a2c3ef79810043dbaf95c3 Mon Sep 17 00:00:00 2001 From: link2026 Date: Tue, 26 May 2026 07:40:42 +0800 Subject: [PATCH] feat(monitor): add UserProfile + MonitorMetric catalog + Indicator.seriesKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据层(spec 2026-05-26): - UserProfile @Model:核心 4 项 + 健康背景 + 用药,SwiftData 单例(loadOrCreate) - Indicator 加 seriesKey: String?,标识长期指标分组('bp.systolic' 等) - MonitorMetric enum 8 case:血压(2 field 拆 2 Indicator)/ 空腹+餐后血糖 / 体重 / 体温 / 心率 / SpO2 / 身高 - effectiveRange(for:profile:) 实现 1 条 Profile-aware 规则: age >= 65 时 bp.systolic 上限 140→150 - KangkangApp schema 加 UserProfile.self 测试 17 个全绿(UserProfile 6 + MonitorMetric 11);schema 烟测扩 2(seriesKey roundtrip + UserProfile persist)。 UI 层 + Timeline 合并下个 commit。 --- 康康/App/KangkangApp.swift | 1 + 康康/Features/Monitor/MonitorMetric.swift | 162 ++++++++++++++++++++++ 康康/Models/Models.swift | 9 +- 康康/Models/UserProfile.swift | 97 +++++++++++++ 康康Tests/ModelsSchemaTests.swift | 49 +++++++ 康康Tests/MonitorMetricTests.swift | 84 +++++++++++ 康康Tests/UserProfileTests.swift | 83 +++++++++++ 7 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 康康/Features/Monitor/MonitorMetric.swift create mode 100644 康康/Models/UserProfile.swift create mode 100644 康康Tests/MonitorMetricTests.swift create mode 100644 康康Tests/UserProfileTests.swift diff --git a/康康/App/KangkangApp.swift b/康康/App/KangkangApp.swift index d23d3d7..cb25cbf 100644 --- a/康康/App/KangkangApp.swift +++ b/康康/App/KangkangApp.swift @@ -11,6 +11,7 @@ struct KangkangApp: App { Asset.self, ChatTurn.self, Symptom.self, + UserProfile.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) do { diff --git a/康康/Features/Monitor/MonitorMetric.swift b/康康/Features/Monitor/MonitorMetric.swift new file mode 100644 index 0000000..62a0d1e --- /dev/null +++ b/康康/Features/Monitor/MonitorMetric.swift @@ -0,0 +1,162 @@ +import Foundation + +/// 长期监测指标预设目录。`IndicatorRecordSheet` 顶部 grid 由 `MonitorMetric.allCases` 渲染。 +/// +/// 录入时按 metric 展开 1 或 2 个 Field;血压拆 2 条 Indicator(同 capturedAt + 各自 seriesKey), +/// 其他预设产 1 条。`effectiveRange(for:profile:)` 用 Profile 调整参考范围(目前只 1 条规则: +/// 老人收缩压上限 140→150)。 +enum MonitorMetric: String, CaseIterable, Identifiable { + case bloodPressure // bp.systolic + bp.diastolic + case fastingGlucose // glucose.fasting + case postprandialGlucose // glucose.postprandial + case weight // weight + case temperature // temperature + case heartRate // heart_rate + case spo2 // spo2 + case height // height(录入后回写 UserProfile.heightCM) + + var id: String { rawValue } + + var displayName: String { + switch self { + case .bloodPressure: return "血压" + case .fastingGlucose: return "空腹血糖" + case .postprandialGlucose: return "餐后血糖" + case .weight: return "体重" + case .temperature: return "体温" + case .heartRate: return "心率" + case .spo2: return "血氧" + case .height: return "身高" + } + } + + /// SF Symbol。grid 卡片图标。 + var icon: String { + switch self { + case .bloodPressure: return "heart.fill" + case .fastingGlucose: return "drop.fill" + case .postprandialGlucose: return "drop.circle.fill" + case .weight: return "scalemass.fill" + case .temperature: return "thermometer.medium" + case .heartRate: return "waveform.path.ecg" + case .spo2: return "lungs.fill" + case .height: return "ruler.fill" + } + } + + var fields: [Field] { + switch self { + case .bloodPressure: + return [ + Field(seriesKey: "bp.systolic", + label: "收缩压", + unit: "mmHg", + placeholder: "120", + baseRange: 90...140), + Field(seriesKey: "bp.diastolic", + label: "舒张压", + unit: "mmHg", + placeholder: "80", + baseRange: 60...90), + ] + case .fastingGlucose: + return [Field(seriesKey: "glucose.fasting", + label: "空腹血糖", + unit: "mmol/L", + placeholder: "5.0", + baseRange: 3.9...6.1)] + case .postprandialGlucose: + return [Field(seriesKey: "glucose.postprandial", + label: "餐后 2h", + unit: "mmol/L", + placeholder: "6.5", + baseRange: 0...7.8)] + case .weight: + return [Field(seriesKey: "weight", + label: "体重", + unit: "kg", + placeholder: "68", + baseRange: nil)] + case .temperature: + return [Field(seriesKey: "temperature", + label: "体温", + unit: "°C", + placeholder: "36.5", + baseRange: 36.0...37.2)] + case .heartRate: + return [Field(seriesKey: "heart_rate", + label: "心率", + unit: "bpm", + placeholder: "72", + baseRange: 60...100)] + case .spo2: + return [Field(seriesKey: "spo2", + label: "血氧", + unit: "%", + placeholder: "98", + baseRange: 95...100)] + case .height: + return [Field(seriesKey: "height", + label: "身高", + unit: "cm", + placeholder: "175", + baseRange: nil)] + } + } +} + +extension MonitorMetric { + struct Field: Identifiable, Hashable { + let seriesKey: String + let label: String + let unit: String + let placeholder: String + let baseRange: ClosedRange? + + var id: String { seriesKey } + + /// 给 IndicatorRecordSheet 显示在数值旁的「90-140 mmHg」字样。 + func rangeText(_ range: ClosedRange?) -> String { + guard let r = range else { return "无参考范围" } + let lower = format(r.lowerBound) + let upper = format(r.upperBound) + // 餐后血糖 baseRange 是 0...7.8,显示成「<7.8」 + if r.lowerBound == 0 { return "<\(upper) \(unit)" } + return "\(lower)–\(upper) \(unit)" + } + + private func format(_ v: Double) -> String { + v.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", v) + : String(format: "%.1f", v) + } + } + + /// 给定 field 在 profile 下的有效参考范围。 + /// 目前只 1 条规则:age ≥ 65 时 bp.systolic 上限 140 → 150。 + /// profile 为 nil(未设资料)时返回 baseRange。 + func effectiveRange(for field: Field, profile: UserProfile?) -> ClosedRange? { + if let age = profile?.age, age >= 65, field.seriesKey == "bp.systolic" { + return 90...150 + } + return field.baseRange + } + + /// 算出 effectiveRange 后,判定 value 的 status。 + /// value 高于上限 → high;低于下限 → low;在内 → normal;无范围 → normal。 + static func status(value: Double, in range: ClosedRange?) -> IndicatorStatus { + guard let r = range else { return .normal } + if value > r.upperBound { return .high } + if value < r.lowerBound { return .low } + return .normal + } + + /// 给 IndicatorRecordSheet 「按你的年龄(67)调整」提示用: + /// 当 effectiveRange ≠ baseRange 时返回 true。 + func isRangePersonalized(for field: Field, profile: UserProfile?) -> Bool { + guard let p = profile else { return false } + let base = field.baseRange + let eff = effectiveRange(for: field, profile: p) + return base != eff + } +} diff --git a/康康/Models/Models.swift b/康康/Models/Models.swift index 317c85b..48976df 100644 --- a/康康/Models/Models.swift +++ b/康康/Models/Models.swift @@ -33,6 +33,11 @@ final class Indicator { var asset: Asset? var pinned: Bool = false + /// 长期指标系列 key,如 "bp.systolic" / "glucose.fasting" / "weight"。 + /// 来源:IndicatorRecordSheet 选预设时填;VL/Report/自由输入留 nil。 + /// 用途:Trends 按 seriesKey 分组;Timeline 配对(如 bp.systolic + bp.diastolic 合并)。 + var seriesKey: String? + init(name: String, value: String, unit: String, @@ -42,7 +47,8 @@ final class Indicator { capturedAt: Date = .now, report: Report? = nil, asset: Asset? = nil, - pinned: Bool = false) { + pinned: Bool = false, + seriesKey: String? = nil) { self.name = name self.value = value self.unit = unit @@ -53,6 +59,7 @@ final class Indicator { self.report = report self.asset = asset self.pinned = pinned + self.seriesKey = seriesKey } var status: IndicatorStatus { diff --git a/康康/Models/UserProfile.swift b/康康/Models/UserProfile.swift new file mode 100644 index 0000000..68bef4f --- /dev/null +++ b/康康/Models/UserProfile.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData + +@Model +final class UserProfile { + // —— 核心 4 项 —— + var birthYear: Int? // 1990。隐私考虑只存年,不存月日 + var biologicalSexRaw: String // "" / "male" / "female" + var heightCM: Int? + var bloodTypeRaw: String // "" / "A" / "B" / "AB" / "O" + + // —— 健康背景 —— + var allergies: [String] + var chronicConditions: [String] + var familyHistory: [String] + + // —— 当前用药 —— + var currentMedications: [String] + + var updatedAt: Date + + init(birthYear: Int? = nil, + biologicalSexRaw: String = "", + heightCM: Int? = nil, + bloodTypeRaw: String = "", + allergies: [String] = [], + chronicConditions: [String] = [], + familyHistory: [String] = [], + currentMedications: [String] = [], + updatedAt: Date = .now) { + self.birthYear = birthYear + self.biologicalSexRaw = biologicalSexRaw + self.heightCM = heightCM + self.bloodTypeRaw = bloodTypeRaw + self.allergies = allergies + self.chronicConditions = chronicConditions + self.familyHistory = familyHistory + self.currentMedications = currentMedications + self.updatedAt = updatedAt + } +} + +extension UserProfile { + enum Sex: String, CaseIterable { + case male, female + case undisclosed = "" + + var label: String { + switch self { + case .male: return "男" + case .female: return "女" + case .undisclosed: return "不愿透露" + } + } + } + + var sex: Sex { + get { Sex(rawValue: biologicalSexRaw) ?? .undisclosed } + set { biologicalSexRaw = newValue.rawValue } + } + + /// 当前年龄。无 birthYear 时返回 nil。基于当前日历年简单相减,不算月日。 + var age: Int? { + guard let y = birthYear else { return nil } + return Calendar.current.component(.year, from: .now) - y + } + + /// 给 ProfileCard 一行预览:"38岁 · 男 · 175cm · A型" + var summaryLine: String { + var parts: [String] = [] + if let age { parts.append("\(age)岁") } + if sex != .undisclosed { parts.append(sex.label) } + if let h = heightCM { parts.append("\(h)cm") } + if !bloodTypeRaw.isEmpty { parts.append("\(bloodTypeRaw)型") } + return parts.joined(separator: " · ") + } + + /// 资料是否完整到值得显示 summaryLine(否则提示"完善资料") + var hasAnyBasics: Bool { + birthYear != nil || sex != .undisclosed || heightCM != nil || !bloodTypeRaw.isEmpty + } +} + +/// 单例存取:全 App 只允许一份 UserProfile。第一次取自动创建。 +enum UserProfileStore { + @MainActor + static func loadOrCreate(in ctx: ModelContext) -> UserProfile { + let descriptor = FetchDescriptor() + if let existing = try? ctx.fetch(descriptor).first { + return existing + } + let new = UserProfile() + ctx.insert(new) + try? ctx.save() + return new + } +} diff --git a/康康Tests/ModelsSchemaTests.swift b/康康Tests/ModelsSchemaTests.swift index dbdea48..042a552 100644 --- a/康康Tests/ModelsSchemaTests.swift +++ b/康康Tests/ModelsSchemaTests.swift @@ -13,6 +13,7 @@ struct ModelsSchemaTests { Asset.self, ChatTurn.self, Symptom.self, + UserProfile.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) return try ModelContainer(for: schema, configurations: [config]) @@ -108,4 +109,52 @@ struct ModelsSchemaTests { #expect(all.count == 1) #expect(all.first?.referencedIndicatorIDs == ["abc"]) } + + @Test func indicatorSeriesKeyRoundtrip() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let bp = Indicator( + name: "收缩压", + value: "125", + unit: "mmHg", + range: "90-140", + status: .normal, + pinned: true, + seriesKey: "bp.systolic" + ) + ctx.insert(bp) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.seriesKey == "bp.systolic") + #expect(fetched.pinned == true) + } + + @Test func indicatorSeriesKeyDefaultsToNil() { + let i = Indicator(name: "ALT", value: "32", unit: "U/L", range: "9-50", status: .normal) + #expect(i.seriesKey == nil) + } + + @Test func userProfileSchemaPersistsAcrossSave() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + + let p = UserProfile( + birthYear: 1985, + biologicalSexRaw: "male", + heightCM: 175, + bloodTypeRaw: "A", + chronicConditions: ["高血压"] + ) + ctx.insert(p) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.birthYear == 1985) + #expect(fetched.sex == .male) + #expect(fetched.heightCM == 175) + #expect(fetched.bloodTypeRaw == "A") + #expect(fetched.chronicConditions == ["高血压"]) + } } diff --git a/康康Tests/MonitorMetricTests.swift b/康康Tests/MonitorMetricTests.swift new file mode 100644 index 0000000..2538c9a --- /dev/null +++ b/康康Tests/MonitorMetricTests.swift @@ -0,0 +1,84 @@ +import Testing +import Foundation +@testable import 康康 + +@MainActor +struct MonitorMetricTests { + + @Test func allMetricsHaveAtLeastOneField() { + for m in MonitorMetric.allCases { + #expect(!m.fields.isEmpty, "metric \(m.rawValue) has no fields") + } + } + + @Test func bloodPressureHasTwoFields() { + let bp = MonitorMetric.bloodPressure + #expect(bp.fields.count == 2) + #expect(bp.fields[0].seriesKey == "bp.systolic") + #expect(bp.fields[1].seriesKey == "bp.diastolic") + } + + @Test func statusHighWhenValueAboveRange() { + let s = MonitorMetric.status(value: 150, in: 90...140) + #expect(s == .high) + } + + @Test func statusLowWhenValueBelowRange() { + let s = MonitorMetric.status(value: 80, in: 90...140) + #expect(s == .low) + } + + @Test func statusNormalWhenValueInside() { + let s = MonitorMetric.status(value: 120, in: 90...140) + #expect(s == .normal) + } + + @Test func statusNormalWhenRangeIsNil() { + let s = MonitorMetric.status(value: 999, in: nil) + #expect(s == .normal) + } + + @Test func systolicUpperBoundShiftsForElderly() { + let bp = MonitorMetric.bloodPressure + let systolic = bp.fields[0] + let elderly = UserProfile(birthYear: 1955) // 71 岁 + let range = bp.effectiveRange(for: systolic, profile: elderly) + #expect(range == 90...150) + } + + @Test func systolicUpperBoundUnchangedForYoungAdult() { + let bp = MonitorMetric.bloodPressure + let systolic = bp.fields[0] + let young = UserProfile(birthYear: 1990) + let range = bp.effectiveRange(for: systolic, profile: young) + #expect(range == 90...140) + } + + @Test func systolicUpperBoundUnchangedWhenProfileNil() { + let bp = MonitorMetric.bloodPressure + let systolic = bp.fields[0] + let range = bp.effectiveRange(for: systolic, profile: nil) + #expect(range == 90...140) + } + + @Test func glucoseUnaffectedByAge() { + let g = MonitorMetric.fastingGlucose + let field = g.fields[0] + let elderly = UserProfile(birthYear: 1940) + #expect(g.effectiveRange(for: field, profile: elderly) == field.baseRange) + } + + @Test func isRangePersonalizedTrueForElderlySystolic() { + let bp = MonitorMetric.bloodPressure + let systolic = bp.fields[0] + let elderly = UserProfile(birthYear: 1955) + #expect(bp.isRangePersonalized(for: systolic, profile: elderly) == true) + } + + @Test func isRangePersonalizedFalseForYoungProfile() { + let bp = MonitorMetric.bloodPressure + let systolic = bp.fields[0] + let young = UserProfile(birthYear: 1995) + #expect(bp.isRangePersonalized(for: systolic, profile: young) == false) + } +} diff --git a/康康Tests/UserProfileTests.swift b/康康Tests/UserProfileTests.swift new file mode 100644 index 0000000..e588509 --- /dev/null +++ b/康康Tests/UserProfileTests.swift @@ -0,0 +1,83 @@ +import Testing +import SwiftData +import Foundation +@testable import 康康 + +@MainActor +struct UserProfileTests { + + private func makeContext() throws -> ModelContext { + let schema = Schema([UserProfile.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [config]) + return ModelContext(container) + } + + @Test func freshProfileHasNilDemographics() { + let p = UserProfile() + #expect(p.birthYear == nil) + #expect(p.heightCM == nil) + #expect(p.biologicalSexRaw == "") + #expect(p.bloodTypeRaw == "") + #expect(p.allergies.isEmpty) + #expect(p.chronicConditions.isEmpty) + #expect(p.currentMedications.isEmpty) + #expect(p.familyHistory.isEmpty) + #expect(p.age == nil) + #expect(p.sex == .undisclosed) + #expect(p.hasAnyBasics == false) + #expect(p.summaryLine == "") + } + + @Test func ageComputedFromBirthYear() { + let p = UserProfile(birthYear: 1985) + let currentYear = Calendar.current.component(.year, from: .now) + #expect(p.age == currentYear - 1985) + } + + @Test func sexEnumRoundtripsThroughRaw() { + let p = UserProfile(biologicalSexRaw: "male") + #expect(p.sex == .male) + p.sex = .female + #expect(p.biologicalSexRaw == "female") + p.sex = .undisclosed + #expect(p.biologicalSexRaw == "") + } + + @Test func summaryLineSkipsEmptyFields() { + let p = UserProfile(birthYear: 1990, biologicalSexRaw: "female", heightCM: 162) + let line = p.summaryLine + #expect(line.contains("岁")) + #expect(line.contains("女")) + #expect(line.contains("162cm")) + #expect(!line.contains("型")) // 血型空,不出现 + } + + @Test func loadOrCreateReturnsExistingSingleton() throws { + let ctx = try makeContext() + let first = UserProfileStore.loadOrCreate(in: ctx) + first.birthYear = 1990 + try ctx.save() + + let second = UserProfileStore.loadOrCreate(in: ctx) + #expect(second.birthYear == 1990) + + let all = try ctx.fetch(FetchDescriptor()) + #expect(all.count == 1) + } + + @Test func arrayFieldsRoundtripThroughSwiftData() throws { + let ctx = try makeContext() + let p = UserProfile( + chronicConditions: ["高血压", "糖尿病"], + currentMedications: ["缬沙坦 80mg qd", "二甲双胍 500mg bid"] + ) + ctx.insert(p) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.chronicConditions == ["高血压", "糖尿病"]) + #expect(fetched.currentMedications.count == 2) + #expect(fetched.currentMedications.first == "缬沙坦 80mg qd") + } +}