Files
kangkang/康康/Services/ExportTrendBuilder.swift
link2026 77a4ee1c37 缺少代码差异信息,无法生成具体的commit message。请提供code differences内容以便分析并生成符合Angular规范的提交信息。
当您提供代码差异后,我将按照以下格式生成:

```
<type>(<scope>): <subject>

<body>
```

其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
2026-06-07 14:17:18 +08:00

182 lines
7.3 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// ##
///
/// `docs/superpowers/specs/2026-06-07-export-indicator-trends-design.md`:
/// 2 ,
/// ( + + + ),** LLM**, `ReportCompareService` ,
/// (§10#5 退 / §12#6 )
struct ExportTrend: Sendable {
enum Direction: Sendable {
case up, down, flat
var arrow: String {
switch self {
case .up: return ""
case .down: return ""
case .flat: return ""
}
}
}
let title: String
let unit: String
/// "152138" "152/96138/88"
let valueText: String
let direction: Direction
/// , "90-140";( / ) nil
let rangeText: String?
///
let spanDays: Int
///
let count: Int
/// ,
let flagged: Bool
/// :` 152138 mmHg 90-140 21 4 `
func line() -> String {
var s = flagged ? "⚠️ " : ""
s += title
s += " \(valueText)"
if !unit.isEmpty { s += " \(unit)" }
s += " \(direction.arrow)"
if let r = rangeText, !r.isEmpty { s += "(参考 \(r)" }
s += ",近 \(spanDays)\(count)"
return s
}
}
enum ExportTrendBuilder {
/// : < 5% ()
static let flatThreshold = 0.05
///
/// - Parameters:
/// - allInWindow: ****()
/// - relevant: ****() series
/// - profile: ( SeriesBucket)
/// - customMetrics: , series /
/// - Returns: ,
static func build(allInWindow: [Indicator],
relevant: [Indicator],
profile: UserProfile? = nil,
customMetrics: [CustomMonitorMetric] = []) -> [ExportTrend] {
let relevantIDs = Set(relevant.compactMap { bucketID(for: $0) })
guard !relevantIDs.isEmpty else { return [] }
// Trends : seriesKey name+unit 退minPoints2
let buckets = SeriesBucket.build(from: allInWindow,
profile: profile,
customMetrics: customMetrics,
minPoints: 2)
let trends = buckets
.filter { relevantIDs.contains($0.id) }
.compactMap { trend(from: $0) }
// ,
return trends.sorted { lhs, rhs in
if lhs.flagged != rhs.flagged { return lhs.flagged }
return lhs.spanDays >= rhs.spanDays // , buckets
}
}
/// SeriesBucket id( `SeriesBucket.build` id )
/// nil series()
static func bucketID(for i: Indicator) -> String? {
if let k = i.seriesKey, !k.isEmpty {
if k == "bp.systolic" || k == "bp.diastolic" { return "bp" }
return k
}
let nk = SeriesBucket.normalizedKey(name: i.name, unit: i.unit)
return nk.isEmpty ? nil : "lab:\(nk)"
}
// MARK: - Private
private static func trend(from bucket: SeriesBucket) -> ExportTrend? {
if bucket.id == "bp" { return bpTrend(from: bucket) }
guard let line = bucket.lines.first,
line.points.count >= 2,
let first = line.points.first,
let last = line.points.last else { return nil }
return ExportTrend(
title: bucket.title,
unit: bucket.unit,
valueText: "\(num(first.value))\(num(last.value))",
direction: direction(first: first.value, last: last.value),
rangeText: rangeText(line.referenceRange),
spanDays: spanDays(first.date, last.date),
count: line.points.count,
flagged: last.status != .normal
|| crossedBoundary(first: first.status, last: last.status)
)
}
/// : + ,;(/,)
private static func bpTrend(from bucket: SeriesBucket) -> ExportTrend? {
guard let sys = bucket.lines.first(where: { $0.seriesKey == "bp.systolic" }),
sys.points.count >= 2,
let sFirst = sys.points.first,
let sLast = sys.points.last else { return nil }
let dia = bucket.lines.first { $0.seriesKey == "bp.diastolic" }
let dFirst = dia?.points.first
let dLast = dia?.points.last
let valueText: String
if let dFirst, let dLast {
valueText = "\(num(sFirst.value))/\(num(dFirst.value))\(num(sLast.value))/\(num(dLast.value))"
} else {
valueText = "\(num(sFirst.value))\(num(sLast.value))"
}
let sysFlag = sLast.status != .normal
|| crossedBoundary(first: sFirst.status, last: sLast.status)
let diaFlag = dLast.map { $0.status != .normal } ?? false
return ExportTrend(
title: bucket.title,
unit: bucket.unit,
valueText: valueText,
direction: direction(first: sFirst.value, last: sLast.value),
rangeText: nil,
spanDays: spanDays(sFirst.date, sLast.date),
count: sys.points.count,
flagged: sysFlag || diaFlag
)
}
static func direction(first: Double, last: Double) -> ExportTrend.Direction {
let delta = last - first
let base = abs(first)
let rel = base > 0 ? abs(delta) / base : abs(delta)
if rel < flatThreshold { return .flat }
return delta > 0 ? .up : .down
}
/// ()
static func crossedBoundary(first: IndicatorStatus, last: IndicatorStatus) -> Bool {
(first == .normal) != (last == .normal)
}
static func spanDays(_ from: Date, _ to: Date) -> Int {
let days = to.timeIntervalSince(from) / 86400
return max(1, Int(days.rounded()))
}
static func rangeText(_ r: ClosedRange<Double>?) -> String? {
guard let r else { return nil }
return "\(num(r.lowerBound))-\(num(r.upperBound))"
}
/// :, 0(138.0"138",6.10"6.1")
static func num(_ v: Double) -> String {
if v.truncatingRemainder(dividingBy: 1) == 0 { return String(Int(v)) }
return String(format: "%g", v)
}
}