import SwiftUI import SwiftData import Foundation enum TimelineKind: String, CaseIterable, Identifiable { case indicator, report, symptom, diary var id: String { rawValue } var label: String { switch self { case .indicator: return String(appLoc: "指标") case .report: return String(appLoc: "报告") case .symptom: return String(appLoc: "症状") case .diary: return String(appLoc: "日记") } } var icon: String { switch self { case .indicator: return "drop.fill" case .report: return "doc.fill" case .symptom: return "waveform.path.ecg" case .diary: return "pencil" } } var accent: Color { switch self { case .indicator: return Tj.Palette.brick case .report: return Tj.Palette.ink2 case .symptom: return Tj.Palette.amber case .diary: return Tj.Palette.leaf } } } struct TimelineEntry: Identifiable, Hashable { let id: String let kind: TimelineKind let date: Date let title: String let subtitle: String let trailing: String? let trailingIsAlert: Bool let isOngoing: Bool static func from(indicator i: Indicator) -> TimelineEntry { TimelineEntry( id: "indicator-\(i.persistentModelID)", kind: .indicator, date: i.capturedAt, title: i.name, subtitle: typeSubtitle(for: i), trailing: indicatorValue(i), trailingIsAlert: i.status != .normal, isOngoing: false ) } /// 批处理 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 // 方向箭头按实际 status 给:两值同向才标 ↑/↓;一高一低只标红不给方向 // (旧实现异常一律 ↑,低血压 85/55 会错误显示 ↑)。 let arrow: String switch (sys.status, dia.status) { case (.high, .high), (.high, .normal), (.normal, .high): arrow = " ↑" case (.low, .low), (.low, .normal), (.normal, .low): arrow = " ↓" default: arrow = "" } return TimelineEntry( id: "bp-\(sys.persistentModelID)-\(dia.persistentModelID)", kind: .indicator, date: sys.capturedAt, title: String(appLoc: "血压"), subtitle: String(appLoc: "长期监测"), trailing: "\(sys.value)/\(dia.value) mmHg" + arrow, trailingIsAlert: abnormal, isOngoing: false ) } static func from(report r: Report) -> TimelineEntry { let highCount = r.indicators.filter { $0.status == .high }.count let lowCount = r.indicators.filter { $0.status == .low }.count return TimelineEntry( id: "report-\(r.persistentModelID)", kind: .report, date: r.reportDate, title: r.title, subtitle: "\(r.type.label) · " + String(appLoc: "共 \(r.pageCount) 页"), trailing: abnormalSummary(high: highCount, low: lowCount), trailingIsAlert: highCount + lowCount > 0, isOngoing: false ) } /// 异常计数 → trailing 文案。只高→「N 项偏高」、只低→「N 项偏低」、混合→「N 项异常」、无→nil。 /// 旧实现一律写「N 项偏高」,只含偏低指标的报告会显示与事实相反的结论(demo 翻车点)。 static func abnormalSummary(high: Int, low: Int) -> String? { switch (high, low) { case (0, 0): return nil case (let h, 0): return String(appLoc: "\(h) 项偏高") case (0, let l): return String(appLoc: "\(l) 项偏低") case (let h, let l): return String(appLoc: "\(h + l) 项异常") } } static func from(diary d: DiaryEntry) -> TimelineEntry { TimelineEntry( id: "diary-\(d.persistentModelID)", kind: .diary, date: d.createdAt, title: d.content.firstLine(), subtitle: String(appLoc: "文字日记"), trailing: nil, trailingIsAlert: false, isOngoing: false ) } static func from(symptom s: Symptom) -> TimelineEntry { let ongoing = s.isOngoing let date = s.endedAt ?? s.startedAt let subtitle: String let trailing: String? if ongoing { subtitle = String(appLoc: "症状 · 持续中") trailing = String(appLoc: "持续 \(formatDuration(s.duration))") } else { subtitle = String(appLoc: "症状 · 已结束") trailing = String(appLoc: "持续 \(formatDuration(s.duration))") } return TimelineEntry( id: "symptom-\(s.persistentModelID)", kind: .symptom, date: date, title: s.name, subtitle: subtitle, trailing: trailing, trailingIsAlert: ongoing, isOngoing: ongoing ) } private static func typeSubtitle(for i: Indicator) -> String { if let report = i.report { return String(appLoc: "指标 · \(report.title)") } return String(appLoc: "异常项快拍") } private static func indicatorValue(_ i: Indicator) -> String { let unit = i.unit.isEmpty ? "" : " \(i.unit)" let arrow: String switch i.status { case .high: arrow = " ↑" case .low: arrow = " ↓" case .normal: arrow = "" } return "\(i.value)\(unit)\(arrow)" } } private extension String { func firstLine() -> String { let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) if let line = trimmed.split(whereSeparator: \.isNewline).first { let s = String(line) return s.count > 40 ? String(s.prefix(40)) + "…" : s } return trimmed.isEmpty ? String(appLoc: "(空日记)") : trimmed } }