docs(claude): sync §5/§7/§10 with Monitor+Profile; fix SeriesBucket SwiftData import

- §5 schema 重写为 7 @Model 完整列表(含 UserProfile + Indicator.seriesKey)
- §7 IA 改成 5 槽 TabBar(2 内容 + 中间 + + 2 设置),记录入口 5 个 kind
- §10.6 红线例外清单加 Monitor + Profile(Symptom 也补上)
- SeriesBucket.swift 缺 import SwiftData(persistentModelID 报错)

全套测试 50 case pass / 0 fail / 0 warning。
This commit is contained in:
link2026
2026-05-26 07:53:16 +08:00
parent e2fb631b96
commit 37b47b2076
10 changed files with 1275 additions and 74 deletions

View File

@@ -0,0 +1,191 @@
import SwiftUI
struct CalendarMonthGrid: View {
let monthAnchor: Date
let data: CalendarData
let onTapDay: (Date) -> Void
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2 //
c.locale = Locale(identifier: "zh_CN")
return c
}()
private let weekdayLabels = ["", "", "", "", "", "", ""]
private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
private var days: [DayCell] {
guard let monthInterval = calendar.dateInterval(of: .month, for: monthAnchor) else {
return []
}
let firstOfMonth = monthInterval.start
let weekdayIndex = (calendar.component(.weekday, from: firstOfMonth) - calendar.firstWeekday + 7) % 7
let daysInMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)?.count ?? 30
var cells: [DayCell] = []
// leading padding ()
for offset in (0..<weekdayIndex).reversed() {
if let d = calendar.date(byAdding: .day, value: -(offset + 1), to: firstOfMonth) {
cells.append(DayCell(date: d, inCurrentMonth: false))
}
}
// current month days
for i in 0..<daysInMonth {
if let d = calendar.date(byAdding: .day, value: i, to: firstOfMonth) {
cells.append(DayCell(date: d, inCurrentMonth: true))
}
}
// trailing padding () 6 = 42
while cells.count < 42 {
if let last = cells.last,
let next = calendar.date(byAdding: .day, value: 1, to: last.date) {
cells.append(DayCell(date: next, inCurrentMonth: false))
} else { break }
}
return cells
}
var body: some View {
VStack(spacing: 8) {
HStack(spacing: 4) {
ForEach(weekdayLabels, id: \.self) { w in
Text(w)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity)
}
}
LazyVGrid(columns: columns, spacing: 4) {
ForEach(days) { cell in
DayCellView(
cell: cell,
ranges: data.ranges(touching: cell.date, calendar: calendar),
marks: data.marks(for: cell.date, calendar: calendar),
isToday: calendar.isDateInToday(cell.date),
calendar: calendar
)
.onTapGesture { onTapDay(cell.date) }
}
}
}
}
}
struct DayCell: Identifiable, Hashable {
let date: Date
let inCurrentMonth: Bool
var id: String { "\(date.timeIntervalSince1970)" }
}
private struct DayCellView: View {
let cell: DayCell
let ranges: [SymptomRange]
let marks: DayMarks
let isToday: Bool
let calendar: Calendar
private var dayNumber: Int {
calendar.component(.day, from: cell.date)
}
var body: some View {
ZStack(alignment: .top) {
// :
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(isToday ? Tj.Palette.sand2 : Color.clear)
VStack(spacing: 2) {
Text("\(dayNumber)")
.font(.system(size: 13,
weight: isToday ? .bold : .regular,
design: .default))
.foregroundStyle(textColor)
.padding(.top, 4)
//
if !ranges.isEmpty {
VStack(spacing: 1) {
ForEach(Array(ranges.prefix(2).enumerated()), id: \.element.id) { _, range in
symptomBar(range)
}
if ranges.count > 2 {
Text("+\(ranges.count - 2)")
.font(.system(size: 7, design: .monospaced))
.foregroundStyle(Tj.Palette.text3)
}
}
}
// /
if marks.hasAnyEvent {
HStack(spacing: 2) {
if marks.abnormalCount > 0 {
Circle().fill(Tj.Palette.brick).frame(width: 4, height: 4)
}
if marks.reportCount > 0 {
Circle().fill(Tj.Palette.ink2).frame(width: 4, height: 4)
}
if marks.normalCount > 0 && marks.abnormalCount == 0 {
Circle().fill(Tj.Palette.leaf).frame(width: 4, height: 4)
}
if marks.diaryCount > 0 {
Circle().fill(Tj.Palette.text3.opacity(0.7)).frame(width: 4, height: 4)
}
}
}
Spacer(minLength: 0)
}
}
.frame(height: 56)
.contentShape(Rectangle())
}
private var textColor: Color {
if !cell.inCurrentMonth { return Tj.Palette.text3.opacity(0.5) }
if isToday { return Tj.Palette.ink }
return Tj.Palette.text
}
private func symptomBar(_ range: SymptomRange) -> some View {
let pos = range.position(cell.date, calendar: calendar)
let leadingRadius: CGFloat = (pos == .start || pos == .single) ? 3 : 0
let trailingRadius: CGFloat = (pos == .end || pos == .single) ? 3 : 0
return GeometryReader { geo in
UnevenRoundedRectangle(
topLeadingRadius: leadingRadius,
bottomLeadingRadius: leadingRadius,
bottomTrailingRadius: trailingRadius,
topTrailingRadius: trailingRadius,
style: .continuous
)
.fill(range.color)
.frame(
width: barWidth(for: pos, in: geo.size.width),
height: 4
)
.frame(maxWidth: .infinity,
alignment: barAlignment(for: pos))
}
.frame(height: 4)
}
private func barWidth(for pos: SymptomRange.Position, in cellWidth: CGFloat) -> CGFloat {
switch pos {
case .single: return cellWidth - 8
case .start, .end: return cellWidth - 2
case .middle: return cellWidth + 4 //
}
}
private func barAlignment(for pos: SymptomRange.Position) -> Alignment {
switch pos {
case .start: return .leading
case .end: return .trailing
case .single: return .center
case .middle: return .center
}
}
}