feat(timeline): merge bp.systolic + bp.diastolic into single entry
- TimelineEntry.from(indicators:) 批处理:找 bp.systolic 配对同 capturedAt (±5s)的 bp.diastolic,合并成 '血压 120/80 mmHg' 一行 - 未配对的 systolic 单独退回 from(indicator:) - 非 bp.* series 不动 - ArchiveListView + HomeView 改用 from(indicators:) 批处理 - 6 个新测试覆盖配对/未配对/异常标记/非 bp 不动/不同时间不合并
This commit is contained in:
@@ -19,7 +19,7 @@ struct ArchiveListView: View {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private var allEntries: [TimelineEntry] {
|
private var allEntries: [TimelineEntry] {
|
||||||
let mapped =
|
let mapped =
|
||||||
indicators.map(TimelineEntry.from(indicator:)) +
|
TimelineEntry.from(indicators: indicators) +
|
||||||
reports.map(TimelineEntry.from(report:)) +
|
reports.map(TimelineEntry.from(report:)) +
|
||||||
diaries.map(TimelineEntry.from(diary:)) +
|
diaries.map(TimelineEntry.from(diary:)) +
|
||||||
symptoms.map(TimelineEntry.from(symptom:))
|
symptoms.map(TimelineEntry.from(symptom:))
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ struct HomeView: View {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private var recentEntries: [TimelineEntry] {
|
private var recentEntries: [TimelineEntry] {
|
||||||
let all =
|
let all =
|
||||||
indicators.map(TimelineEntry.from(indicator:)) +
|
TimelineEntry.from(indicators: indicators) +
|
||||||
reports.map(TimelineEntry.from(report:)) +
|
reports.map(TimelineEntry.from(report:)) +
|
||||||
diaries.map(TimelineEntry.from(diary:)) +
|
diaries.map(TimelineEntry.from(diary:)) +
|
||||||
symptoms.map(TimelineEntry.from(symptom:))
|
symptoms.map(TimelineEntry.from(symptom:))
|
||||||
|
|||||||
@@ -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<PersistentIdentifier>()
|
||||||
|
|
||||||
|
// 先找 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 {
|
static func from(report r: Report) -> TimelineEntry {
|
||||||
let abnormal = r.indicators.filter { $0.status != .normal }.count
|
let abnormal = r.indicators.filter { $0.status != .normal }.count
|
||||||
return TimelineEntry(
|
return TimelineEntry(
|
||||||
|
|||||||
116
康康Tests/TimelineEntryBPMergeTests.swift
Normal file
116
康康Tests/TimelineEntryBPMergeTests.swift
Normal file
@@ -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<Indicator>())
|
||||||
|
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<Indicator>())
|
||||||
|
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<Indicator>())
|
||||||
|
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<Indicator>())
|
||||||
|
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<Indicator>())
|
||||||
|
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<Indicator>())
|
||||||
|
let entries = TimelineEntry.from(indicators: all)
|
||||||
|
#expect(entries.count == 1)
|
||||||
|
#expect(entries.first?.title == "ALT")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user