Files
kangkang/docs/superpowers/specs/2026-05-26-monitor-and-profile-design.md
link2026 7ede38ae06 docs(spec): add Monitor + Profile design v1 (approved)
long-term formatted indicators(.indicator 入口预设 + 自由)+ 个人资料
(年龄/性别/身高/血型/健康背景/用药)+ Profile-aware reference range
(老人血压 90-150 替代 90-140)。详见 spec §2-§5。
2026-05-26 07:34:43 +08:00

16 KiB

Monitor + Profile · 设计 v1

长期格式化指标录入(.indicator 入口预设 + 自由)+ 个人资料(年龄、性别、健康背景、用药)

日期:2026-05-26 · 状态:approved by user,进入实施 关联:CLAUDE.md §5 §7 §10;W2 retro 计划外完成


1. 背景与目标

1.1 当前缺口

康康现有的 4 个记录 kind(quick 拍照、archive 归档、diary 文字、symptom 持续症状)都是事件型——一次性记录,不假设后续会重复同一指标。但血压/血糖/体重这类长期监测类需求:

  • 用户每天/每周测,数值规律地重复
  • 需要趋势(W4-W5 计划的 Trends 页)
  • 不需要拍照(已是格式化数字)
  • 参考范围依赖个人 demographic(老人血压标准放宽)

同时,App 启动以来一直没有用户基础信息持久化的位置。LLM 给出趋势解读时缺乏 demographic context("LDL 偏高"对 35 岁健康男和 70 岁糖尿病患者风险完全不同)。

1.2 目标

  • 一个统一的"手动录入指标"入口:用户已加 .indicator case,本设计把 7 个预设(血压/血糖/体重/...)和「自由输入」合并进这个 sheet
  • 个人资料卡:在「我的」加一张资料卡,push 进 Form 编辑页,4 项核心 + 健康背景 + 用药
  • 联动:参考范围按 Profile 个性化(目前规则只覆盖"老人血压"一例,后续可扩)

1.3 非目标(YAGNI)

  • Trends 页升级(本次只打通数据通路,留给 W4-W5)
  • 提醒/通知功能(到点测量推送)
  • HealthKit 导入
  • 多 Profile / 给家人记
  • AppLock / Face ID(W5 末统一实现)
  • 单位切换(kg/lb,mmol/L vs mg/dL)
  • 紧急联系人

2. 数据模型

2.1 Indicator 扩字段

@Model final class Indicator {
    // 现有字段不动:name/value/unit/range/statusRaw/note/capturedAt/report/asset/pinned

    var seriesKey: String?   // 新增。预设录入填 "bp.systolic" / "glucose.fasting" / ...
                             // 自由输入和 VL/Report 来源的 Indicator 都为 nil
}

为什么用 String 而非 enum:seriesKey 跨设备/版本要稳定,enum 改名会破坏老数据;String 用命名空间约定(bp.* / glucose.*)即可。

为什么不新建 @Model:复用 Indicator 让 Trends/Timeline/ReportCompareService 一次写完受益,避免分裂查询路径。

2.2 UserProfile @Model

@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, /* ... */) { /* ... */ }
}

extension UserProfile {
    enum Sex: String { case male, female, undisclosed = "" }
    var sex: Sex { Sex(rawValue: biologicalSexRaw) ?? .undisclosed }

    /// 当前年龄(无 birthYear 时 nil)
    var age: Int? {
        guard let y = birthYear else { return nil }
        return Calendar.current.component(.year, from: .now) - y
    }
}

2.3 单例策略

UserProfile 全 App 单一实例,通过 helper 保证:

enum UserProfileStore {
    @MainActor
    static func loadOrCreate(in ctx: ModelContext) -> UserProfile {
        let descriptor = FetchDescriptor<UserProfile>()
        if let existing = try? ctx.fetch(descriptor).first { return existing }
        let new = UserProfile()
        ctx.insert(new)
        try? ctx.save()
        return new
    }
}

任何 View 用 @Query 拉,空了再调 loadOrCreate。MeView 启动时调一次,确保后续 @Query 必拿到。

2.4 Schema 注册

KangkangApp.swift 的 schema 加入 UserProfile.self。Indicator 加字段是 additive change,SwiftData 自动迁移(给老 row 的 seriesKey 填 nil)。


3. MonitorMetric Catalog

Features/Monitor/MonitorMetric.swift,8 个预设(血压算 1 个 case,内部展开 2 条 Indicator):

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 { /* "血压" / "空腹血糖" / ... */ }
    var icon: String { /* SF Symbol */ }
    var fields: [Field]      // 1 或 2 个字段定义
}

extension MonitorMetric {
    struct Field {
        let seriesKey: String     // 如 "bp.systolic"
        let label: String         // 如 "收缩压"
        let unit: String          // 如 "mmHg"
        let placeholder: String   // 如 "120"
        let baseRange: ClosedRange<Double>?  // nil 表示不算 status(体重/身高)
    }

    /// 返回该 metric 在给定 profile 下的参考范围(可能跟 baseRange 不同)
    func effectiveRange(for field: Field, profile: UserProfile?) -> ClosedRange<Double>? {
        // 目前唯一规则:bp 在 age >= 65 时上限放宽到 150 / 90
        if let age = profile?.age, age >= 65,
           field.seriesKey == "bp.systolic" {
            return 90...150
        }
        if let age = profile?.age, age >= 65,
           field.seriesKey == "bp.diastolic" {
            return 60...90  // 暂不调
        }
        return field.baseRange
    }

    /// 根据值算 status(value 在范围内 → normal,上 → high,下 → low,无范围 → normal)
    static func status(value: Double, in range: ClosedRange<Double>?) -> IndicatorStatus {
        guard let r = range else { return .normal }
        if value > r.upperBound { return .high }
        if value < r.lowerBound { return .low }
        return .normal
    }
}

3.1 Profile-aware 规则

本次仅实现 1 条规则(老人收缩压上限 140→150),目的是展示联动机制,不追求医学完备。未来扩规则只改 effectiveRange 函数,不动调用方。


4. UI

4.1 IndicatorRecordSheet(替代之前提的 MonitorRecordSheet)

Features/Indicator/IndicatorRecordSheet.swift,被 RootView 在 .indicator case 弹出。

布局:

[拖动条]
"记录指标 · 本地处理"

[2 列 grid]
┌─────────┐ ┌─────────┐
│ 血压    │ │ 空腹血糖│
│ 收/舒   │ │ 3.9-6.1 │
└─────────┘ └─────────┘
┌─────────┐ ┌─────────┐
│ 体重    │ │ 体温    │
└─────────┘ └─────────┘
... (共 7 预设)
┌─────────┐ ┌─────────┐
│ 心率    │ │ + 自由   │
└─────────┘ └─────────┘

—— 选中 metric 后,grid 下方展开 ——

【血压】参考范围:90-140 / 60-90 mmHg(成人通用)
[收缩压 _____  mmHg]
[舒张压 _____  mmHg]
status chip 实时显示

[保存按钮]

关键交互:

  • 进入 sheet 时无选中,grid 全展示
  • 点预设 → 高亮卡片 + 下方展开输入区
  • 切换 metric → 数值清空(避免血压数值串到血糖)
  • 选「+ 自由输入」→ 展开 4 个字段:名称 / 数值 / 单位 / 参考范围(string)
  • 保存:
    • 血压 → 2 条 Indicator(同 capturedAt + 各自 seriesKey)
    • 单字段预设 → 1 条 Indicator(seriesKey 填)
    • 身高预设 → 1 条 Indicator + 回写 UserProfile.heightCM
    • 自由输入 → 1 条 Indicator(seriesKey 为 nil,name 用户输入)

Profile-aware 提示:

  • effectiveRangebaseRange 不同,参考范围一行末尾小字:"按你的年龄(67)调整"
  • effectiveRange 与 baseRange 相同 / 无 Profile,正常显示

4.2 MeView 改造

[ScrollView]

  ┌─────────────────────────────────┐
  │ 个人资料                  更多 →│
  │ 38岁 · 男 · 175cm · A型         │
  │ (未设置时:"点这里完善你的资料") │
  └─────────────────────────────────┘
       ↓ tap push

  ┌─────────────────────────────────┐
  │ 模型管理               未配置 → │  (W6 stub)
  └─────────────────────────────────┘

  ┌─────────────────────────────────┐
  │ Face ID 启动锁         关闭 →   │  (W5 stub)
  └─────────────────────────────────┘

  ┌─────────────────────────────────┐
  │ 关于                      →    │  (链接到隐私承诺 placeholder)
  └─────────────────────────────────┘

  #if DEBUG
  DebugAIRunner
  #endif

stub 卡片本次只放占位 + 文案,push 进去是空页或 placeholder。

4.3 ProfileEditView

Features/Profile/ProfileEditView.swift,Form 风格:

导航标题:个人资料

—— 基本 ——
出生年份         [picker 1900-2026]
性别             [男 / 女 / 不愿透露 segmented]
身高             [TextField + cm]
血型             [A / B / AB / O / 不知道 picker]

—— 健康背景 ——
过敏史           [chips + add field]
慢病             [8 预设 chips 多选 + 自定义 add]
家族史           [chips + add field]

—— 当前用药 ——
[列表 + add row + 行内 swipe-to-delete]

(保存即时,无显式 Save 按钮——边改边写)

慢病 8 预设:高血压 / 糖尿病 / 冠心病 / 高血脂 / 甲状腺疾病 / 哮喘 / 慢性肾病 / 抑郁/焦虑

4.4 Timeline 行内合并(顺手)

Features/Timeline/TimelineEntry.swift,from(indicator:) 增加配对逻辑:

static func from(indicators: [Indicator]) -> [TimelineEntry] {
    // 旧版只 map,新版需要查找 bp.systolic 配对 bp.diastolic
    // 算法:同 capturedAt(到秒)+ bp.* prefix → 合并;其他单条直接 map
}

ArchiveListView 和 HomeView 的 mapped 表达式从 indicators.map(...) 改为 TimelineEntry.from(indicators:)(批处理)。

合并后的 TimelineEntry:

  • title: "血压"
  • subtitle: "120 / 80 mmHg"
  • trailing: 异常时显示"偏高"或"正常"

非 bp.* 的 series 不合并,逐条显示("空腹血糖 5.4 mmol/L" / "体重 68 kg")。


5. 联动:Profile ↔ Monitor

5.1 调用路径

IndicatorRecordSheet
  ↓ @Query UserProfile  (单例)
MonitorMetric.effectiveRange(for: field, profile: profile)
  ↓
  - 显示个性化参考范围
  - 保存时 MonitorMetric.status(value:, in: effectiveRange) 算 statusRaw

5.2 未来扩展点

effectiveRange 是唯一规则入口,扩规则只动这个函数。规则示例(本次不实现):

  • 性别 → 血红蛋白、肌酐参考范围不同
  • 慢病 → 糖尿病患者血糖目标更严
  • 年龄分段 → 儿童体温、心率范围

6. 测试

6.1 新建 康康Tests/UserProfileTests.swift

  • freshProfileHasNilDemographics() — 新建 profile,字段都 nil/空数组
  • ageComputedFromBirthYear() — 1985 → 41 岁(2026 当前年)
  • sexParsesEnumFromRaw() — male/female/空 → 三种 enum
  • loadOrCreateReturnsExistingSingleton() — 第二次 call 不创建新 row
  • arrayFieldsRoundtripThroughSwiftData() — chronicConditions 存读

6.2 新建 康康Tests/MonitorMetricTests.swift

  • allMetricsHaveAtLeastOneField()
  • bpHasTwoFields()
  • statusHighWhenAboveUpper() / statusLowWhenBelowLower() / statusNormalWhenInside() / statusNormalWhenRangeNil()
  • bpUpperBoundShiftsForElderly() — age 67 时 bp.systolic 上限 = 150
  • bpUpperBoundUnchangedWhenNoProfile() — profile 为 nil 时上限 = 140
  • nonBPSeriesUnaffectedByProfile() — 血糖范围不随年龄变

6.3 扩 康康Tests/ModelsSchemaTests.swift

  • userProfileSchemaPersistsAcrossSave()
  • indicatorSeriesKeyRoundtrip()
  • cascadeStillWorksWithSeriesKey() — Report 删除时,关联 Indicator(无论 seriesKey)都删

6.4 扩 康康Tests/TimelineGroupingTests.swift

  • bpSystolicAndDiastolicMergeIntoSingleEntry()
  • nonBPSeriesStayAsSeparateEntries()
  • bpAtDifferentTimesDoNotMerge() — capturedAt 差 > 5 秒不合并

预期总测试数:11(profile 5)+ 7(metric)+ 3(schema)+ 3(timeline)= 18 个新测试。


7. 不变项与守恒检查

  • §10.1 不引入云服务 — 完全本地
  • §10.2 不自实现密码学 — SwiftData store 已有 file protection
  • §10.3 UI 不直接调 AIRuntime — 本设计不涉及 AI
  • §10.4 AIRuntime actor — 不涉及
  • §10.5 VL/LLM prompt — 不涉及
  • ⚠️ §10.6 新功能必须问"清单里有吗" — Monitor 和 Profile 都是清单外,已跟用户确认加入
  • §10.7 不重构现有骨架 — 不动 RootView / RecordSheet 骨架(只补 case 处理),不动 DesignSystem
  • §10.8 C2 ≠ B3 — 不涉及

8. 文件清单

新建(6)

路径 职责
康康/Models/UserProfile.swift UserProfile @Model + Sex enum + age computed + loadOrCreate helper
康康/Features/Monitor/MonitorMetric.swift 8 metric catalog + effectiveRange + status 算法
康康/Features/Indicator/IndicatorRecordSheet.swift 预设 grid + 自由输入合一的录入 sheet
康康/Features/Profile/ProfileEditView.swift Form 编辑页
康康Tests/UserProfileTests.swift 5 测试
康康Tests/MonitorMetricTests.swift 7 测试

修改(7)

路径 改什么
康康/Models/Models.swift Indicator 加 seriesKey: String?,初始化器加默认值 nil
康康/App/KangkangApp.swift schema 加 UserProfile.self
康康/Features/Me/MeView.swift 加 ProfileCard + 3 个 stub 卡片
康康/RootView.swift .indicator case 接 IndicatorRecordSheet 弹出
康康/Features/Timeline/TimelineEntry.swift from(indicators:) 批处理 + bp 配对
康康Tests/ModelsSchemaTests.swift 3 个新测试
康康Tests/TimelineGroupingTests.swift 3 个新测试

文档(2)

路径 改什么
CLAUDE.md §5 加 UserProfile @Model + Indicator seriesKey;§7 IA 加 Profile 入口;§11 时间表加备注;§10.6 例外清单加 Monitor + Profile
docs/superpowers/specs/2026-05-26-monitor-and-profile-design.md 本文件

9. 验收

  • App build & test 全绿,0 警告
  • DEBUG 启动 → 我的 → 个人资料 → 填年龄 + 性别 + 身高 + 血型,push back 显示在 ProfileCard
  • DEBUG 启动 → + 号 → 指标记录 → 选血压 → 输 145/85 → 保存 → 在首页时间线看到合并的"血压 145/85"行
  • 把 UserProfile birthYear 改成 1955(70 岁) → 再次进血压录入 → 顶部小字显示"按你的年龄(70)调整",参考范围 90-150 / 60-90
  • 录入身高 175 → 个人资料卡片自动显示 175cm
  • 18 个新测试全绿

10. 估时

  • 数据层(UserProfile + Indicator.seriesKey + schema 注册):20 分钟
  • MonitorMetric catalog + effectiveRange:20 分钟
  • IndicatorRecordSheet UI:25 分钟
  • ProfileEditView + MeView 改造:25 分钟
  • Timeline 合并:15 分钟
  • 18 测试:30 分钟
  • CLAUDE.md + 提交整理:15 分钟

总计 ~150 分钟(2.5 小时)。