From e2fb631b9678c3996b6f784ef9cb1ac992d721e7 Mon Sep 17 00:00:00 2001 From: link2026 Date: Tue, 26 May 2026 07:50:00 +0800 Subject: [PATCH] feat(timeline): merge bp.systolic + bp.diastolic into single entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TimelineEntry.from(indicators:) 批处理:找 bp.systolic 配对同 capturedAt (±5s)的 bp.diastolic,合并成 '血压 120/80 mmHg' 一行 - 未配对的 systolic 单独退回 from(indicator:) - 非 bp.* series 不动 - ArchiveListView + HomeView 改用 from(indicators:) 批处理 - 6 个新测试覆盖配对/未配对/异常标记/非 bp 不动/不同时间不合并 --- 康康/Features/Archive/ArchiveListView.swift | 2 +- 康康/Features/Home/HomeView.swift | 2 +- 康康/Features/Timeline/TimelineEntry.swift | 41 +++++++ 康康Tests/TimelineEntryBPMergeTests.swift | 116 ++++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 康康Tests/TimelineEntryBPMergeTests.swift diff --git a/康康/Features/Archive/ArchiveListView.swift b/康康/Features/Archive/ArchiveListView.swift index 840d6ce..4b504a2 100644 --- a/康康/Features/Archive/ArchiveListView.swift +++ b/康康/Features/Archive/ArchiveListView.swift @@ -19,7 +19,7 @@ struct ArchiveListView: View { @MainActor private var allEntries: [TimelineEntry] { let mapped = - indicators.map(TimelineEntry.from(indicator:)) + + TimelineEntry.from(indicators: indicators) + reports.map(TimelineEntry.from(report:)) + diaries.map(TimelineEntry.from(diary:)) + symptoms.map(TimelineEntry.from(symptom:)) diff --git a/康康/Features/Home/HomeView.swift b/康康/Features/Home/HomeView.swift index 5616c1f..2d66cbf 100644 --- a/康康/Features/Home/HomeView.swift +++ b/康康/Features/Home/HomeView.swift @@ -19,7 +19,7 @@ struct HomeView: View { @MainActor private var recentEntries: [TimelineEntry] { let all = - indicators.map(TimelineEntry.from(indicator:)) + + TimelineEntry.from(indicators: indicators) + reports.map(TimelineEntry.from(report:)) + diaries.map(TimelineEntry.from(diary:)) + symptoms.map(TimelineEntry.from(symptom:)) diff --git a/康康/Features/Timeline/TimelineEntry.swift b/康康/Features/Timeline/TimelineEntry.swift index 5a65bac..c3b7d94 100644 --- a/康康/Features/Timeline/TimelineEntry.swift +++ b/康康/Features/Timeline/TimelineEntry.swift @@ -57,6 +57,47 @@ struct TimelineEntry: Identifiable, Hashable { ) } + /// 批处理 Indicator 列表,把 bp.systolic + bp.diastolic 同 capturedAt 合并成 + /// 一条 "血压 120/80 mmHg" timeline entry。其他 series 逐条 from(indicator:)。 + /// 合并条件:capturedAt 差 ≤ 5 秒(防止跨次混淆)。 + static func from(indicators: [Indicator]) -> [TimelineEntry] { + var entries: [TimelineEntry] = [] + var consumed = Set() + + // 先找 bp.systolic,配 bp.diastolic + for sys in indicators where sys.seriesKey == "bp.systolic" { + if consumed.contains(sys.persistentModelID) { continue } + guard let dia = indicators.first(where: { + $0.seriesKey == "bp.diastolic" && + !consumed.contains($0.persistentModelID) && + abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5 + }) else { continue } + consumed.insert(sys.persistentModelID) + consumed.insert(dia.persistentModelID) + entries.append(mergedBP(systolic: sys, diastolic: dia)) + } + + // 剩下的 indicator(含未配对的 systolic/diastolic、其他 series、自由输入) + for i in indicators where !consumed.contains(i.persistentModelID) { + entries.append(from(indicator: i)) + } + return entries + } + + private static func mergedBP(systolic sys: Indicator, diastolic dia: Indicator) -> TimelineEntry { + let abnormal = sys.status != .normal || dia.status != .normal + return TimelineEntry( + id: "bp-\(sys.persistentModelID)-\(dia.persistentModelID)", + kind: .indicator, + date: sys.capturedAt, + title: "血压", + subtitle: "长期监测", + trailing: "\(sys.value)/\(dia.value) mmHg" + (abnormal ? " ↑" : ""), + trailingIsAlert: abnormal, + isOngoing: false + ) + } + static func from(report r: Report) -> TimelineEntry { let abnormal = r.indicators.filter { $0.status != .normal }.count return TimelineEntry( diff --git a/康康Tests/TimelineEntryBPMergeTests.swift b/康康Tests/TimelineEntryBPMergeTests.swift new file mode 100644 index 0000000..2737e84 --- /dev/null +++ b/康康Tests/TimelineEntryBPMergeTests.swift @@ -0,0 +1,116 @@ +import Testing +import SwiftData +import Foundation +@testable import 康康 + +@MainActor +struct TimelineEntryBPMergeTests { + + private func makeContext() throws -> ModelContext { + let schema = Schema([Indicator.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return ModelContext(try ModelContainer(for: schema, configurations: [config])) + } + + private func bp(sys: Int, dia: Int, at: Date, in ctx: ModelContext) -> (Indicator, Indicator) { + let s = Indicator(name: "收缩压", value: "\(sys)", unit: "mmHg", + range: "90-140", status: .normal, + capturedAt: at, pinned: true, seriesKey: "bp.systolic") + let d = Indicator(name: "舒张压", value: "\(dia)", unit: "mmHg", + range: "60-90", status: .normal, + capturedAt: at, pinned: true, seriesKey: "bp.diastolic") + ctx.insert(s); ctx.insert(d) + return (s, d) + } + + @Test func bpPairAtSameTimeMergesIntoSingleEntry() throws { + let ctx = try makeContext() + let now = Date(timeIntervalSince1970: 1_716_000_000) + _ = bp(sys: 120, dia: 80, at: now, in: ctx) + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + + #expect(entries.count == 1) + let e = try #require(entries.first) + #expect(e.title == "血压") + #expect(e.trailing?.contains("120/80") == true) + #expect(e.trailingIsAlert == false) + } + + @Test func bpPairWithAbnormalSystolicMarksAlert() throws { + let ctx = try makeContext() + let now = Date() + let (s, _) = bp(sys: 150, dia: 80, at: now, in: ctx) + s.statusRaw = IndicatorStatus.high.rawValue + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + #expect(entries.count == 1) + #expect(entries.first?.trailingIsAlert == true) + #expect(entries.first?.trailing?.contains("↑") == true) + } + + @Test func nonBPSeriesStayAsSeparateEntries() throws { + let ctx = try makeContext() + let now = Date() + let glu = Indicator(name: "空腹血糖", value: "5.4", unit: "mmol/L", + range: "3.9-6.1", status: .normal, + capturedAt: now, pinned: true, seriesKey: "glucose.fasting") + let weight = Indicator(name: "体重", value: "68", unit: "kg", + range: "", status: .normal, + capturedAt: now, pinned: true, seriesKey: "weight") + ctx.insert(glu); ctx.insert(weight) + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + #expect(entries.count == 2) + #expect(entries.contains { $0.title == "空腹血糖" }) + #expect(entries.contains { $0.title == "体重" }) + } + + @Test func bpAtDifferentTimesDoNotMerge() throws { + let ctx = try makeContext() + let t1 = Date(timeIntervalSince1970: 1_716_000_000) + let t2 = t1.addingTimeInterval(3600) // 1 小时后 + _ = bp(sys: 120, dia: 80, at: t1, in: ctx) + _ = bp(sys: 130, dia: 85, at: t2, in: ctx) + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + #expect(entries.count == 2) + } + + @Test func unpairedSystolicFallsBackToSingleEntry() throws { + let ctx = try makeContext() + let s = Indicator(name: "收缩压", value: "120", unit: "mmHg", + range: "90-140", status: .normal, + capturedAt: .now, pinned: true, seriesKey: "bp.systolic") + ctx.insert(s) + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + // 没找到 diastolic 配对,落到单 from(indicator:),显示 "收缩压" + #expect(entries.count == 1) + #expect(entries.first?.title == "收缩压") + } + + @Test func freeformIndicatorWithoutSeriesKeyShowsAsItself() throws { + let ctx = try makeContext() + let i = Indicator(name: "ALT", value: "32", unit: "U/L", + range: "9-50", status: .normal, + capturedAt: .now) + ctx.insert(i) + try ctx.save() + + let all = try ctx.fetch(FetchDescriptor()) + let entries = TimelineEntry.from(indicators: all) + #expect(entries.count == 1) + #expect(entries.first?.title == "ALT") + } +}