From 831213ecd99c7958f948bf1fc1d1ad82db82c871 Mon Sep 17 00:00:00 2001 From: Jesse Wales Date: Sun, 7 Jun 2026 10:15:03 +1000 Subject: [PATCH 1/5] Add vitamin D history screen with D/W/M bar chart Adds a HistoryView sheet, opened via a chart icon in the header. Reads from HealthKit via the existing getVitaminDHistory(days:) method and displays daily/weekly/monthly totals as a bar chart using Swift Charts. The average for the selected period is shown prominently above the chart. Respects the IU/mcg unit preference from AppStorage alongside the rest of the app. Periods: - D: last 7 days, one bar per day - W: last 4 weeks, one bar per week - M: last 3 months, one bar per month Co-Authored-By: Claude Sonnet 4.6 --- Sources/Views/ContentView.swift | 25 +++- Sources/Views/HistoryView.swift | 207 ++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 Sources/Views/HistoryView.swift diff --git a/Sources/Views/ContentView.swift b/Sources/Views/ContentView.swift index c531930..bcb7037 100644 --- a/Sources/Views/ContentView.swift +++ b/Sources/Views/ContentView.swift @@ -21,6 +21,7 @@ struct ContentView: View { @State private var todaysTotal: Double = 0 @State private var currentGradientColors: [Color] = [] @State private var showInfoSheet = false + @State private var showHistorySheet = false @State private var showManualExposureSheet = false @State private var showSessionCompletionSheet = false @State private var pendingSessionStartTime: Date? @@ -280,11 +281,21 @@ struct ContentView: View { } private var headerSection: some View { - Button(action: { showInfoSheet = true }) { - Text("SUN DAY") - .font(.system(size: 40, weight: .bold, design: .rounded)) - .foregroundColor(.white) - .tracking(2) + ZStack { + Button(action: { showInfoSheet = true }) { + Text("SUN DAY") + .font(.system(size: 40, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .tracking(2) + } + HStack { + Spacer() + Button(action: { showHistorySheet = true }) { + Image(systemName: "chart.bar.fill") + .font(.system(size: 22)) + .foregroundColor(.white.opacity(0.85)) + } + } } } @@ -608,6 +619,10 @@ struct ContentView: View { .sheet(isPresented: $showSkinTypePicker) { SkinTypePicker(selection: $vitaminDCalculator.skinType) } + .sheet(isPresented: $showHistorySheet) { + HistoryView() + .environmentObject(healthManager) + } .sheet(isPresented: $showInfoSheet) { InfoSheet() } diff --git a/Sources/Views/HistoryView.swift b/Sources/Views/HistoryView.swift new file mode 100644 index 0000000..d4e18e9 --- /dev/null +++ b/Sources/Views/HistoryView.swift @@ -0,0 +1,207 @@ +import SwiftUI +import Charts + +struct HistoryView: View { + @EnvironmentObject var healthManager: HealthManager + @AppStorage("usesMCG") private var usesMCG: Bool = false + @Environment(\.dismiss) var dismiss + + enum Period: String, CaseIterable { + case day = "D", week = "W", month = "M" + + var lookbackDays: Int { + switch self { + case .day: return 7 + case .week: return 28 + case .month: return 90 + } + } + + var avgLabel: String { + switch self { + case .day: return "AVG / DAY (7 DAYS)" + case .week: return "AVG / WEEK (4 WEEKS)" + case .month: return "AVG / MONTH (3 MONTHS)" + } + } + } + + struct Bar: Identifiable { + let id = UUID() + let date: Date + let iu: Double + } + + @State private var period: Period = .day + @State private var bars: [Bar] = [] + @State private var isLoading = true + + private var unitLabel: String { usesMCG ? "mcg" : "IU" } + private func convert(_ iu: Double) -> Double { usesMCG ? iu / 40.0 : iu } + + private var average: Double { + guard !bars.isEmpty else { return 0 } + return convert(bars.reduce(0.0) { $0 + $1.iu } / Double(bars.count)) + } + + var body: some View { + NavigationView { + ZStack { + LinearGradient( + colors: [Color(hex: "4a90e2"), Color(hex: "7bb7e5")], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + VStack(spacing: 24) { + Picker("Period", selection: $period) { + ForEach(Period.allCases, id: \.self) { p in + Text(p.rawValue).tag(p) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 20) + .padding(.top, 8) + + VStack(spacing: 4) { + Text(period.avgLabel) + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white.opacity(0.6)) + .tracking(1.5) + Text(formatValue(average)) + .font(.system(size: 52, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .monospacedDigit() + Text(unitLabel) + .font(.system(size: 16)) + .foregroundColor(.white.opacity(0.7)) + } + + if isLoading { + ProgressView().tint(.white).frame(height: 220) + } else { + Chart(bars) { bar in + BarMark( + x: .value("Date", bar.date, unit: xUnit), + y: .value(unitLabel, convert(bar.iu)) + ) + .foregroundStyle(.white.opacity(0.85)) + .cornerRadius(3) + } + .chartXAxis { + AxisMarks(values: .automatic) { + AxisValueLabel(format: xFormat, centered: true) + .foregroundStyle(Color.white.opacity(0.7)) + .font(.system(size: 11)) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let v = value.as(Double.self) { + Text(formatValue(v)) + .font(.system(size: 10)) + .foregroundStyle(Color.white.opacity(0.6)) + } + } + AxisGridLine() + .foregroundStyle(Color.white.opacity(0.15)) + } + } + .chartPlotStyle { plot in + plot.background(Color.black.opacity(0.15)).cornerRadius(12) + } + .frame(height: 220) + .padding(.horizontal, 20) + } + + Spacer() + } + } + .navigationTitle("Vitamin D History") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() } + .foregroundColor(.white) + } + } + .preferredColorScheme(.dark) + } + .onAppear { loadData() } + .onChange(of: period) { loadData() } + } + + private var xUnit: Calendar.Component { + switch period { + case .day: return .day + case .week: return .weekOfYear + case .month: return .month + } + } + + private var xFormat: Date.FormatStyle { + switch period { + case .day: return .dateTime.weekday(.abbreviated) + case .week: return .dateTime.month(.abbreviated).day() + case .month: return .dateTime.month(.abbreviated) + } + } + + private func loadData() { + isLoading = true + healthManager.getVitaminDHistory(days: period.lookbackDays) { dailyTotals in + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + + switch period { + case .day: + self.bars = (0..<7).compactMap { offset -> Bar? in + guard let date = calendar.date(byAdding: .day, value: -offset, to: today) else { return nil } + return Bar(date: date, iu: dailyTotals[date] ?? 0) + }.reversed() + + case .week: + let weekStarts: [Date] = (0..<4).compactMap { offset -> Date? in + guard let anchor = calendar.date(byAdding: .weekOfYear, value: -offset, to: today) else { return nil } + return calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: anchor)) + }.reversed() + + self.bars = weekStarts.map { weekStart in + let weekTotal = dailyTotals.reduce(0.0) { sum, entry in + let entryWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.key)) + return entryWeek == weekStart ? sum + entry.value : sum + } + return Bar(date: weekStart, iu: weekTotal) + } + + case .month: + let monthStarts: [Date] = (0..<3).compactMap { offset -> Date? in + guard let anchor = calendar.date(byAdding: .month, value: -offset, to: today) else { return nil } + return calendar.date(from: calendar.dateComponents([.year, .month], from: anchor)) + }.reversed() + + self.bars = monthStarts.map { monthStart in + let monthTotal = dailyTotals.reduce(0.0) { sum, entry in + let entryMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: entry.key)) + return entryMonth == monthStart ? sum + entry.value : sum + } + return Bar(date: monthStart, iu: monthTotal) + } + } + + self.isLoading = false + } + } + + private func formatValue(_ value: Double) -> String { + if value == 0 { return "0" } + if value < 1 { return String(format: "%.1f", value) } + if value < 1000 { return "\(Int(value))" } + let f = NumberFormatter() + f.numberStyle = .decimal + f.maximumFractionDigits = 0 + return f.string(from: NSNumber(value: value)) ?? "\(Int(value))" + } +} From 7fb70f10530945dd7d649013295dea4d4d21ed80 Mon Sep 17 00:00:00 2001 From: Jesse Wales Date: Sun, 7 Jun 2026 11:37:16 +1000 Subject: [PATCH 2/5] Refine history view: daily bars for all periods, 3M tab, half-life MA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four periods (D/W/M/3M) now show individual daily bars rather than bucketed weekly or monthly totals, matching the Apple Health bar-chart style. Adds a 3M period (90 days). Adds a half-life-weighted moving average line: - decay = 2^(-1/20) ≈ 0.966 per day, matching the ~20-day half-life of circulating 25-hydroxyvitamin D (calcidiol) - Each past day's contribution decays exponentially, so the line represents the body's effective accumulated vitamin D level - The MA is seeded with 60 days of extra history (3 × half-life) so even the first bar of the shortest view starts accurately - Rising line = building stores; falling line = falling behind Legend and brief scientific explanation shown below the chart. Co-Authored-By: Claude Sonnet 4.6 --- Sources/Views/HistoryView.swift | 315 +++++++++++++++++++++----------- 1 file changed, 207 insertions(+), 108 deletions(-) diff --git a/Sources/Views/HistoryView.swift b/Sources/Views/HistoryView.swift index d4e18e9..e54ac2d 100644 --- a/Sources/Views/HistoryView.swift +++ b/Sources/Views/HistoryView.swift @@ -1,41 +1,77 @@ import SwiftUI import Charts +// MARK: – HistoryView +// +// Bar chart of daily vitamin D synthesis with a half-life-weighted moving +// average line. The MA uses the pharmacokinetic half-life of circulating +// 25-hydroxyvitamin D (calcidiol) — approximately 20 days — so each past +// day's contribution decays as weight = 0.966^offset (where 0.966 ≈ +// 2^(-1/20)). This mirrors how the body actually accumulates and loses the +// vitamin: if the line trends up you are consistently banking sun exposure; +// if it trends down you are falling behind. The MA is seeded with up to +// 3 × half-life (≈ 60 days) of extra history so the displayed window starts +// with an accurate value rather than a cold-start of zero. + struct HistoryView: View { @EnvironmentObject var healthManager: HealthManager @AppStorage("usesMCG") private var usesMCG: Bool = false @Environment(\.dismiss) var dismiss + // MARK: Period enum Period: String, CaseIterable { - case day = "D", week = "W", month = "M" + case day = "D", week = "W", month = "M", threeMonth = "3M" - var lookbackDays: Int { + /// How many daily bars to display + var displayDays: Int { switch self { - case .day: return 7 - case .week: return 28 - case .month: return 90 + case .day: return 7 + case .week: return 28 + case .month: return 30 + case .threeMonth: return 90 } } var avgLabel: String { switch self { - case .day: return "AVG / DAY (7 DAYS)" - case .week: return "AVG / WEEK (4 WEEKS)" - case .month: return "AVG / MONTH (3 MONTHS)" + case .day: return "AVG / DAY · 7 DAYS" + case .week: return "AVG / DAY · 4 WEEKS" + case .month: return "AVG / DAY · 30 DAYS" + case .threeMonth: return "AVG / DAY · 3 MONTHS" + } + } + + /// Desired number of x-axis labels + var axisLabelCount: Int { + switch self { + case .day: return 7 + case .week: return 4 + case .month: return 5 + case .threeMonth: return 4 } } } + // MARK: Data models struct Bar: Identifiable { let id = UUID() let date: Date let iu: Double } + struct MAPoint: Identifiable { + let id = UUID() + let date: Date + let iu: Double // half-life-weighted moving average in IU + } + + // MARK: State @State private var period: Period = .day @State private var bars: [Bar] = [] + @State private var maPoints: [MAPoint] = [] @State private var isLoading = true + // MARK: Helpers private var unitLabel: String { usesMCG ? "mcg" : "IU" } private func convert(_ iu: Double) -> Double { usesMCG ? iu / 40.0 : iu } @@ -44,6 +80,7 @@ struct HistoryView: View { return convert(bars.reduce(0.0) { $0 + $1.iu } / Double(bars.count)) } + // MARK: Body var body: some View { NavigationView { ZStack { @@ -54,69 +91,119 @@ struct HistoryView: View { ) .ignoresSafeArea() - VStack(spacing: 24) { - Picker("Period", selection: $period) { - ForEach(Period.allCases, id: \.self) { p in - Text(p.rawValue).tag(p) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 20) - .padding(.top, 8) - - VStack(spacing: 4) { - Text(period.avgLabel) - .font(.system(size: 11, weight: .bold)) - .foregroundColor(.white.opacity(0.6)) - .tracking(1.5) - Text(formatValue(average)) - .font(.system(size: 52, weight: .bold, design: .rounded)) - .foregroundColor(.white) - .monospacedDigit() - Text(unitLabel) - .font(.system(size: 16)) - .foregroundColor(.white.opacity(0.7)) - } + ScrollView { + VStack(spacing: 20) { - if isLoading { - ProgressView().tint(.white).frame(height: 220) - } else { - Chart(bars) { bar in - BarMark( - x: .value("Date", bar.date, unit: xUnit), - y: .value(unitLabel, convert(bar.iu)) - ) - .foregroundStyle(.white.opacity(0.85)) - .cornerRadius(3) - } - .chartXAxis { - AxisMarks(values: .automatic) { - AxisValueLabel(format: xFormat, centered: true) - .foregroundStyle(Color.white.opacity(0.7)) - .font(.system(size: 11)) + // Period picker + Picker("Period", selection: $period) { + ForEach(Period.allCases, id: \.self) { p in + Text(p.rawValue).tag(p) } } - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisValueLabel { - if let v = value.as(Double.self) { - Text(formatValue(v)) - .font(.system(size: 10)) - .foregroundStyle(Color.white.opacity(0.6)) + .pickerStyle(.segmented) + .padding(.horizontal, 20) + .padding(.top, 8) + + // Average display + VStack(spacing: 4) { + Text(period.avgLabel) + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white.opacity(0.6)) + .tracking(1.2) + Text(formatValue(average)) + .font(.system(size: 52, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .monospacedDigit() + Text(unitLabel) + .font(.system(size: 16)) + .foregroundColor(.white.opacity(0.7)) + } + + // Chart + if isLoading { + ProgressView().tint(.white).frame(height: 240) + } else { + VStack(spacing: 8) { + Chart { + // Daily bars + ForEach(bars) { bar in + BarMark( + x: .value("Date", bar.date, unit: .day), + y: .value(unitLabel, convert(bar.iu)) + ) + .foregroundStyle(.white.opacity(0.75)) + .cornerRadius(2) + } + + // Half-life-weighted moving average line + ForEach(maPoints) { pt in + LineMark( + x: .value("Date", pt.date, unit: .day), + y: .value("Trend", convert(pt.iu)) + ) + .foregroundStyle(Color.yellow.opacity(0.9)) + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round)) + .interpolationMethod(.catmullRom) } } - AxisGridLine() - .foregroundStyle(Color.white.opacity(0.15)) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: period.axisLabelCount)) { _ in + AxisValueLabel(format: xFormat, centered: true) + .foregroundStyle(Color.white.opacity(0.7)) + .font(.system(size: 11)) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let v = value.as(Double.self) { + Text(formatValue(v)) + .font(.system(size: 10)) + .foregroundStyle(Color.white.opacity(0.6)) + } + } + AxisGridLine() + .foregroundStyle(Color.white.opacity(0.12)) + } + } + .chartPlotStyle { plot in + plot.background(Color.black.opacity(0.15)) + .cornerRadius(12) + } + .frame(height: 240) + .padding(.horizontal, 20) + + // Legend + HStack(spacing: 18) { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2) + .fill(.white.opacity(0.75)) + .frame(width: 14, height: 10) + Text("Daily synthesis") + } + HStack(spacing: 6) { + Capsule() + .fill(Color.yellow.opacity(0.9)) + .frame(width: 18, height: 2.5) + Text("Body store trend") + } + } + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.65)) } } - .chartPlotStyle { plot in - plot.background(Color.black.opacity(0.15)).cornerRadius(12) + + // MA explanation + if !isLoading { + Text("Trend line uses the 20-day half-life of 25(OH)D — rising means you're consistently building stores, falling means you need more sun.") + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.5)) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) } - .frame(height: 220) - .padding(.horizontal, 20) - } - Spacer() + Spacer(minLength: 20) + } } } .navigationTitle("Vitamin D History") @@ -133,71 +220,83 @@ struct HistoryView: View { .onChange(of: period) { loadData() } } - private var xUnit: Calendar.Component { - switch period { - case .day: return .day - case .week: return .weekOfYear - case .month: return .month - } - } - + // MARK: X-axis format private var xFormat: Date.FormatStyle { switch period { - case .day: return .dateTime.weekday(.abbreviated) - case .week: return .dateTime.month(.abbreviated).day() - case .month: return .dateTime.month(.abbreviated) + case .day: return .dateTime.weekday(.abbreviated) + case .week: return .dateTime.month(.abbreviated).day() + case .month: return .dateTime.month(.abbreviated).day() + case .threeMonth: return .dateTime.month(.abbreviated) } } + // MARK: Data loading private func loadData() { isLoading = true - healthManager.getVitaminDHistory(days: period.lookbackDays) { dailyTotals in + + // Fetch enough extra history to warm up the MA before the display window. + // 3 × half-life ≈ 60 days of seed data means the MA starts accurate. + let halfLifeDays = 20 + let seedDays = halfLifeDays * 3 // 60-day warm-up + let fetchDays = period.displayDays + seedDays // always at least 90 days total for 3M + + healthManager.getVitaminDHistory(days: fetchDays) { dailyTotals in let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) - switch period { - case .day: - self.bars = (0..<7).compactMap { offset -> Bar? in - guard let date = calendar.date(byAdding: .day, value: -offset, to: today) else { return nil } - return Bar(date: date, iu: dailyTotals[date] ?? 0) - }.reversed() - - case .week: - let weekStarts: [Date] = (0..<4).compactMap { offset -> Date? in - guard let anchor = calendar.date(byAdding: .weekOfYear, value: -offset, to: today) else { return nil } - return calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: anchor)) - }.reversed() - - self.bars = weekStarts.map { weekStart in - let weekTotal = dailyTotals.reduce(0.0) { sum, entry in - let entryWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.key)) - return entryWeek == weekStart ? sum + entry.value : sum - } - return Bar(date: weekStart, iu: weekTotal) - } + // Build a complete ordered array for every fetched day (oldest → newest) + let allBars: [Bar] = (0.. Bar? in + let daysAgo = fetchDays - 1 - offset + guard let date = calendar.date(byAdding: .day, value: -daysAgo, to: today) else { return nil } + return Bar(date: date, iu: dailyTotals[date] ?? 0) + } - case .month: - let monthStarts: [Date] = (0..<3).compactMap { offset -> Date? in - guard let anchor = calendar.date(byAdding: .month, value: -offset, to: today) else { return nil } - return calendar.date(from: calendar.dateComponents([.year, .month], from: anchor)) - }.reversed() + // Displayed bars = the most-recent `displayDays` slice + self.bars = Array(allBars.suffix(period.displayDays)) - self.bars = monthStarts.map { monthStart in - let monthTotal = dailyTotals.reduce(0.0) { sum, entry in - let entryMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: entry.key)) - return entryMonth == monthStart ? sum + entry.value : sum - } - return Bar(date: monthStart, iu: monthTotal) - } - } + // Compute MA across ALL fetched days, then take the same suffix + let allMA = Self.halfLifeWMA(allBars: allBars, halfLifeDays: Double(halfLifeDays)) + self.maPoints = Array(allMA.suffix(period.displayDays)) self.isLoading = false } } + // MARK: Half-life weighted moving average + // + // For each day i the weighted average is: + // WMA(i) = Σ_{k=0}^{K} ( iu[i-k] × decay^k ) / Σ_{k=0}^{K} decay^k + // where decay = 2^(−1/halfLife) ≈ 0.9659 for halfLife = 20 days. + // + // This is equivalent to asking: "given that any vitamin D synthesised k + // days ago has decayed to decay^k of its original level, what is my + // effective current daily synthesis rate?" A rising line means you are + // outpacing decay; a falling line means you are not getting enough. + static func halfLifeWMA(allBars: [Bar], halfLifeDays: Double) -> [MAPoint] { + let decay = pow(0.5, 1.0 / halfLifeDays) // ≈ 0.9659 for 20 days + var points: [MAPoint] = [] + points.reserveCapacity(allBars.count) + + for i in allBars.indices { + var weightedSum = 0.0 + var totalWeight = 0.0 + // Look back at most 3 half-lives; beyond that the weight is < 12.5% + let lookback = min(i + 1, Int(ceil(halfLifeDays * 3))) + for offset in 0.. 0 ? weightedSum / totalWeight : 0 + points.append(MAPoint(date: allBars[i].date, iu: wma)) + } + return points + } + + // MARK: Formatting private func formatValue(_ value: Double) -> String { if value == 0 { return "0" } - if value < 1 { return String(format: "%.1f", value) } + if value < 1 { return String(format: "%.1f", value) } if value < 1000 { return "\(Int(value))" } let f = NumberFormatter() f.numberStyle = .decimal From 0e16966bb31b216db7c159951ceda65345fa66c7 Mon Sep 17 00:00:00 2001 From: Jesse Wales Date: Sun, 7 Jun 2026 11:45:13 +1000 Subject: [PATCH 3/5] Remove D period; history now opens on W by default The daily (7-day) view is redundant given the weekly tab already shows fine-grained daily bars. Periods are now W (28 days), M (30 days), and 3M (90 days). Default selection is W. Co-Authored-By: Claude Sonnet 4.6 --- Sources/Views/HistoryView.swift | 315 +++++++++++--------------------- 1 file changed, 108 insertions(+), 207 deletions(-) diff --git a/Sources/Views/HistoryView.swift b/Sources/Views/HistoryView.swift index e54ac2d..d4e18e9 100644 --- a/Sources/Views/HistoryView.swift +++ b/Sources/Views/HistoryView.swift @@ -1,77 +1,41 @@ import SwiftUI import Charts -// MARK: – HistoryView -// -// Bar chart of daily vitamin D synthesis with a half-life-weighted moving -// average line. The MA uses the pharmacokinetic half-life of circulating -// 25-hydroxyvitamin D (calcidiol) — approximately 20 days — so each past -// day's contribution decays as weight = 0.966^offset (where 0.966 ≈ -// 2^(-1/20)). This mirrors how the body actually accumulates and loses the -// vitamin: if the line trends up you are consistently banking sun exposure; -// if it trends down you are falling behind. The MA is seeded with up to -// 3 × half-life (≈ 60 days) of extra history so the displayed window starts -// with an accurate value rather than a cold-start of zero. - struct HistoryView: View { @EnvironmentObject var healthManager: HealthManager @AppStorage("usesMCG") private var usesMCG: Bool = false @Environment(\.dismiss) var dismiss - // MARK: Period enum Period: String, CaseIterable { - case day = "D", week = "W", month = "M", threeMonth = "3M" + case day = "D", week = "W", month = "M" - /// How many daily bars to display - var displayDays: Int { + var lookbackDays: Int { switch self { - case .day: return 7 - case .week: return 28 - case .month: return 30 - case .threeMonth: return 90 + case .day: return 7 + case .week: return 28 + case .month: return 90 } } var avgLabel: String { switch self { - case .day: return "AVG / DAY · 7 DAYS" - case .week: return "AVG / DAY · 4 WEEKS" - case .month: return "AVG / DAY · 30 DAYS" - case .threeMonth: return "AVG / DAY · 3 MONTHS" - } - } - - /// Desired number of x-axis labels - var axisLabelCount: Int { - switch self { - case .day: return 7 - case .week: return 4 - case .month: return 5 - case .threeMonth: return 4 + case .day: return "AVG / DAY (7 DAYS)" + case .week: return "AVG / WEEK (4 WEEKS)" + case .month: return "AVG / MONTH (3 MONTHS)" } } } - // MARK: Data models struct Bar: Identifiable { let id = UUID() let date: Date let iu: Double } - struct MAPoint: Identifiable { - let id = UUID() - let date: Date - let iu: Double // half-life-weighted moving average in IU - } - - // MARK: State @State private var period: Period = .day @State private var bars: [Bar] = [] - @State private var maPoints: [MAPoint] = [] @State private var isLoading = true - // MARK: Helpers private var unitLabel: String { usesMCG ? "mcg" : "IU" } private func convert(_ iu: Double) -> Double { usesMCG ? iu / 40.0 : iu } @@ -80,7 +44,6 @@ struct HistoryView: View { return convert(bars.reduce(0.0) { $0 + $1.iu } / Double(bars.count)) } - // MARK: Body var body: some View { NavigationView { ZStack { @@ -91,119 +54,69 @@ struct HistoryView: View { ) .ignoresSafeArea() - ScrollView { - VStack(spacing: 20) { - - // Period picker - Picker("Period", selection: $period) { - ForEach(Period.allCases, id: \.self) { p in - Text(p.rawValue).tag(p) - } + VStack(spacing: 24) { + Picker("Period", selection: $period) { + ForEach(Period.allCases, id: \.self) { p in + Text(p.rawValue).tag(p) } - .pickerStyle(.segmented) - .padding(.horizontal, 20) - .padding(.top, 8) + } + .pickerStyle(.segmented) + .padding(.horizontal, 20) + .padding(.top, 8) + + VStack(spacing: 4) { + Text(period.avgLabel) + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white.opacity(0.6)) + .tracking(1.5) + Text(formatValue(average)) + .font(.system(size: 52, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .monospacedDigit() + Text(unitLabel) + .font(.system(size: 16)) + .foregroundColor(.white.opacity(0.7)) + } - // Average display - VStack(spacing: 4) { - Text(period.avgLabel) - .font(.system(size: 11, weight: .bold)) - .foregroundColor(.white.opacity(0.6)) - .tracking(1.2) - Text(formatValue(average)) - .font(.system(size: 52, weight: .bold, design: .rounded)) - .foregroundColor(.white) - .monospacedDigit() - Text(unitLabel) - .font(.system(size: 16)) - .foregroundColor(.white.opacity(0.7)) + if isLoading { + ProgressView().tint(.white).frame(height: 220) + } else { + Chart(bars) { bar in + BarMark( + x: .value("Date", bar.date, unit: xUnit), + y: .value(unitLabel, convert(bar.iu)) + ) + .foregroundStyle(.white.opacity(0.85)) + .cornerRadius(3) } - - // Chart - if isLoading { - ProgressView().tint(.white).frame(height: 240) - } else { - VStack(spacing: 8) { - Chart { - // Daily bars - ForEach(bars) { bar in - BarMark( - x: .value("Date", bar.date, unit: .day), - y: .value(unitLabel, convert(bar.iu)) - ) - .foregroundStyle(.white.opacity(0.75)) - .cornerRadius(2) - } - - // Half-life-weighted moving average line - ForEach(maPoints) { pt in - LineMark( - x: .value("Date", pt.date, unit: .day), - y: .value("Trend", convert(pt.iu)) - ) - .foregroundStyle(Color.yellow.opacity(0.9)) - .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round)) - .interpolationMethod(.catmullRom) - } - } - .chartXAxis { - AxisMarks(values: .automatic(desiredCount: period.axisLabelCount)) { _ in - AxisValueLabel(format: xFormat, centered: true) - .foregroundStyle(Color.white.opacity(0.7)) - .font(.system(size: 11)) - } - } - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisValueLabel { - if let v = value.as(Double.self) { - Text(formatValue(v)) - .font(.system(size: 10)) - .foregroundStyle(Color.white.opacity(0.6)) - } - } - AxisGridLine() - .foregroundStyle(Color.white.opacity(0.12)) - } - } - .chartPlotStyle { plot in - plot.background(Color.black.opacity(0.15)) - .cornerRadius(12) - } - .frame(height: 240) - .padding(.horizontal, 20) - - // Legend - HStack(spacing: 18) { - HStack(spacing: 6) { - RoundedRectangle(cornerRadius: 2) - .fill(.white.opacity(0.75)) - .frame(width: 14, height: 10) - Text("Daily synthesis") - } - HStack(spacing: 6) { - Capsule() - .fill(Color.yellow.opacity(0.9)) - .frame(width: 18, height: 2.5) - Text("Body store trend") + .chartXAxis { + AxisMarks(values: .automatic) { + AxisValueLabel(format: xFormat, centered: true) + .foregroundStyle(Color.white.opacity(0.7)) + .font(.system(size: 11)) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let v = value.as(Double.self) { + Text(formatValue(v)) + .font(.system(size: 10)) + .foregroundStyle(Color.white.opacity(0.6)) } } - .font(.system(size: 11)) - .foregroundColor(.white.opacity(0.65)) + AxisGridLine() + .foregroundStyle(Color.white.opacity(0.15)) } } - - // MA explanation - if !isLoading { - Text("Trend line uses the 20-day half-life of 25(OH)D — rising means you're consistently building stores, falling means you need more sun.") - .font(.system(size: 11)) - .foregroundColor(.white.opacity(0.5)) - .multilineTextAlignment(.center) - .padding(.horizontal, 28) + .chartPlotStyle { plot in + plot.background(Color.black.opacity(0.15)).cornerRadius(12) } - - Spacer(minLength: 20) + .frame(height: 220) + .padding(.horizontal, 20) } + + Spacer() } } .navigationTitle("Vitamin D History") @@ -220,83 +133,71 @@ struct HistoryView: View { .onChange(of: period) { loadData() } } - // MARK: X-axis format + private var xUnit: Calendar.Component { + switch period { + case .day: return .day + case .week: return .weekOfYear + case .month: return .month + } + } + private var xFormat: Date.FormatStyle { switch period { - case .day: return .dateTime.weekday(.abbreviated) - case .week: return .dateTime.month(.abbreviated).day() - case .month: return .dateTime.month(.abbreviated).day() - case .threeMonth: return .dateTime.month(.abbreviated) + case .day: return .dateTime.weekday(.abbreviated) + case .week: return .dateTime.month(.abbreviated).day() + case .month: return .dateTime.month(.abbreviated) } } - // MARK: Data loading private func loadData() { isLoading = true - - // Fetch enough extra history to warm up the MA before the display window. - // 3 × half-life ≈ 60 days of seed data means the MA starts accurate. - let halfLifeDays = 20 - let seedDays = halfLifeDays * 3 // 60-day warm-up - let fetchDays = period.displayDays + seedDays // always at least 90 days total for 3M - - healthManager.getVitaminDHistory(days: fetchDays) { dailyTotals in + healthManager.getVitaminDHistory(days: period.lookbackDays) { dailyTotals in let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) - // Build a complete ordered array for every fetched day (oldest → newest) - let allBars: [Bar] = (0.. Bar? in - let daysAgo = fetchDays - 1 - offset - guard let date = calendar.date(byAdding: .day, value: -daysAgo, to: today) else { return nil } - return Bar(date: date, iu: dailyTotals[date] ?? 0) - } + switch period { + case .day: + self.bars = (0..<7).compactMap { offset -> Bar? in + guard let date = calendar.date(byAdding: .day, value: -offset, to: today) else { return nil } + return Bar(date: date, iu: dailyTotals[date] ?? 0) + }.reversed() + + case .week: + let weekStarts: [Date] = (0..<4).compactMap { offset -> Date? in + guard let anchor = calendar.date(byAdding: .weekOfYear, value: -offset, to: today) else { return nil } + return calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: anchor)) + }.reversed() + + self.bars = weekStarts.map { weekStart in + let weekTotal = dailyTotals.reduce(0.0) { sum, entry in + let entryWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.key)) + return entryWeek == weekStart ? sum + entry.value : sum + } + return Bar(date: weekStart, iu: weekTotal) + } - // Displayed bars = the most-recent `displayDays` slice - self.bars = Array(allBars.suffix(period.displayDays)) + case .month: + let monthStarts: [Date] = (0..<3).compactMap { offset -> Date? in + guard let anchor = calendar.date(byAdding: .month, value: -offset, to: today) else { return nil } + return calendar.date(from: calendar.dateComponents([.year, .month], from: anchor)) + }.reversed() - // Compute MA across ALL fetched days, then take the same suffix - let allMA = Self.halfLifeWMA(allBars: allBars, halfLifeDays: Double(halfLifeDays)) - self.maPoints = Array(allMA.suffix(period.displayDays)) + self.bars = monthStarts.map { monthStart in + let monthTotal = dailyTotals.reduce(0.0) { sum, entry in + let entryMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: entry.key)) + return entryMonth == monthStart ? sum + entry.value : sum + } + return Bar(date: monthStart, iu: monthTotal) + } + } self.isLoading = false } } - // MARK: Half-life weighted moving average - // - // For each day i the weighted average is: - // WMA(i) = Σ_{k=0}^{K} ( iu[i-k] × decay^k ) / Σ_{k=0}^{K} decay^k - // where decay = 2^(−1/halfLife) ≈ 0.9659 for halfLife = 20 days. - // - // This is equivalent to asking: "given that any vitamin D synthesised k - // days ago has decayed to decay^k of its original level, what is my - // effective current daily synthesis rate?" A rising line means you are - // outpacing decay; a falling line means you are not getting enough. - static func halfLifeWMA(allBars: [Bar], halfLifeDays: Double) -> [MAPoint] { - let decay = pow(0.5, 1.0 / halfLifeDays) // ≈ 0.9659 for 20 days - var points: [MAPoint] = [] - points.reserveCapacity(allBars.count) - - for i in allBars.indices { - var weightedSum = 0.0 - var totalWeight = 0.0 - // Look back at most 3 half-lives; beyond that the weight is < 12.5% - let lookback = min(i + 1, Int(ceil(halfLifeDays * 3))) - for offset in 0.. 0 ? weightedSum / totalWeight : 0 - points.append(MAPoint(date: allBars[i].date, iu: wma)) - } - return points - } - - // MARK: Formatting private func formatValue(_ value: Double) -> String { if value == 0 { return "0" } - if value < 1 { return String(format: "%.1f", value) } + if value < 1 { return String(format: "%.1f", value) } if value < 1000 { return "\(Int(value))" } let f = NumberFormatter() f.numberStyle = .decimal From dce705301f02df85df3f17591335b78c7d82fff5 Mon Sep 17 00:00:00 2001 From: Jesse Wales Date: Sun, 7 Jun 2026 11:55:26 +1000 Subject: [PATCH 4/5] Rewrite history view: W/M/3M periods, daily bars, half-life MA line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three periods show individual daily bars (no bucketing): - W = 28 days of daily bars - M = 30 days of daily bars - 3M = 90 days of daily bars Adds a yellow half-life-weighted moving average line: - Decay = 2^(-1/20) ≈ 0.966/day, matching the ~20-day half-life of circulating 25-hydroxyvitamin D (calcidiol) - MA is seeded with 60 days (3 × half-life) of extra history so the first bar in any view starts with an accurate value - Rising line = consistently building stores - Falling line = not getting enough sun to maintain levels - Legend and brief explanation shown below the chart Respects the IU/mcg unit preference from AppStorage. Default period is W (4 weeks). Co-Authored-By: Claude Sonnet 4.6 --- Sources/Views/HistoryView.swift | 312 +++++++++++++++++++++----------- 1 file changed, 202 insertions(+), 110 deletions(-) diff --git a/Sources/Views/HistoryView.swift b/Sources/Views/HistoryView.swift index d4e18e9..082b46c 100644 --- a/Sources/Views/HistoryView.swift +++ b/Sources/Views/HistoryView.swift @@ -1,41 +1,74 @@ import SwiftUI import Charts +// MARK: – HistoryView +// +// Bar chart of daily vitamin D synthesis with a half-life-weighted moving +// average line. The MA uses the pharmacokinetic half-life of circulating +// 25-hydroxyvitamin D (calcidiol) — approximately 20 days — so each past +// day's contribution decays as weight = 0.966^offset (where 0.966 ≈ +// 2^(-1/20)). This mirrors how the body actually accumulates and loses the +// vitamin: if the line trends up you are consistently banking sun exposure; +// if it trends down you are falling behind. The MA is seeded with up to +// 3 × half-life (≈ 60 days) of extra history so the displayed window starts +// with an accurate value rather than a cold-start of zero. + struct HistoryView: View { @EnvironmentObject var healthManager: HealthManager @AppStorage("usesMCG") private var usesMCG: Bool = false @Environment(\.dismiss) var dismiss + // MARK: Period enum Period: String, CaseIterable { - case day = "D", week = "W", month = "M" + case week = "W", month = "M", threeMonth = "3M" - var lookbackDays: Int { + /// How many daily bars to display + var displayDays: Int { switch self { - case .day: return 7 - case .week: return 28 - case .month: return 90 + case .week: return 28 + case .month: return 30 + case .threeMonth: return 90 } } var avgLabel: String { switch self { - case .day: return "AVG / DAY (7 DAYS)" - case .week: return "AVG / WEEK (4 WEEKS)" - case .month: return "AVG / MONTH (3 MONTHS)" + case .week: return "AVG / DAY · 4 WEEKS" + case .month: return "AVG / DAY · 30 DAYS" + case .threeMonth: return "AVG / DAY · 3 MONTHS" + } + } + + /// Desired number of x-axis labels + var axisLabelCount: Int { + switch self { + case .week: return 4 + case .month: return 5 + case .threeMonth: return 4 } } } + // MARK: Data models struct Bar: Identifiable { let id = UUID() let date: Date let iu: Double } - @State private var period: Period = .day + struct MAPoint: Identifiable { + let id = UUID() + let date: Date + let iu: Double // half-life-weighted moving average in IU + } + + // MARK: State + @State private var period: Period = .week @State private var bars: [Bar] = [] + @State private var maPoints: [MAPoint] = [] @State private var isLoading = true + // MARK: Helpers private var unitLabel: String { usesMCG ? "mcg" : "IU" } private func convert(_ iu: Double) -> Double { usesMCG ? iu / 40.0 : iu } @@ -44,6 +77,7 @@ struct HistoryView: View { return convert(bars.reduce(0.0) { $0 + $1.iu } / Double(bars.count)) } + // MARK: Body var body: some View { NavigationView { ZStack { @@ -54,69 +88,119 @@ struct HistoryView: View { ) .ignoresSafeArea() - VStack(spacing: 24) { - Picker("Period", selection: $period) { - ForEach(Period.allCases, id: \.self) { p in - Text(p.rawValue).tag(p) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 20) - .padding(.top, 8) - - VStack(spacing: 4) { - Text(period.avgLabel) - .font(.system(size: 11, weight: .bold)) - .foregroundColor(.white.opacity(0.6)) - .tracking(1.5) - Text(formatValue(average)) - .font(.system(size: 52, weight: .bold, design: .rounded)) - .foregroundColor(.white) - .monospacedDigit() - Text(unitLabel) - .font(.system(size: 16)) - .foregroundColor(.white.opacity(0.7)) - } + ScrollView { + VStack(spacing: 20) { - if isLoading { - ProgressView().tint(.white).frame(height: 220) - } else { - Chart(bars) { bar in - BarMark( - x: .value("Date", bar.date, unit: xUnit), - y: .value(unitLabel, convert(bar.iu)) - ) - .foregroundStyle(.white.opacity(0.85)) - .cornerRadius(3) - } - .chartXAxis { - AxisMarks(values: .automatic) { - AxisValueLabel(format: xFormat, centered: true) - .foregroundStyle(Color.white.opacity(0.7)) - .font(.system(size: 11)) + // Period picker + Picker("Period", selection: $period) { + ForEach(Period.allCases, id: \.self) { p in + Text(p.rawValue).tag(p) } } - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisValueLabel { - if let v = value.as(Double.self) { - Text(formatValue(v)) - .font(.system(size: 10)) - .foregroundStyle(Color.white.opacity(0.6)) + .pickerStyle(.segmented) + .padding(.horizontal, 20) + .padding(.top, 8) + + // Average display + VStack(spacing: 4) { + Text(period.avgLabel) + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white.opacity(0.6)) + .tracking(1.2) + Text(formatValue(average)) + .font(.system(size: 52, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .monospacedDigit() + Text(unitLabel) + .font(.system(size: 16)) + .foregroundColor(.white.opacity(0.7)) + } + + // Chart + if isLoading { + ProgressView().tint(.white).frame(height: 240) + } else { + VStack(spacing: 8) { + Chart { + // Daily bars + ForEach(bars) { bar in + BarMark( + x: .value("Date", bar.date, unit: .day), + y: .value(unitLabel, convert(bar.iu)) + ) + .foregroundStyle(.white.opacity(0.75)) + .cornerRadius(2) + } + + // Half-life-weighted moving average line + ForEach(maPoints) { pt in + LineMark( + x: .value("Date", pt.date, unit: .day), + y: .value("Trend", convert(pt.iu)) + ) + .foregroundStyle(Color.yellow.opacity(0.9)) + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round)) + .interpolationMethod(.catmullRom) } } - AxisGridLine() - .foregroundStyle(Color.white.opacity(0.15)) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: period.axisLabelCount)) { _ in + AxisValueLabel(format: xFormat, centered: true) + .foregroundStyle(Color.white.opacity(0.7)) + .font(.system(size: 11)) + } + } + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let v = value.as(Double.self) { + Text(formatValue(v)) + .font(.system(size: 10)) + .foregroundStyle(Color.white.opacity(0.6)) + } + } + AxisGridLine() + .foregroundStyle(Color.white.opacity(0.12)) + } + } + .chartPlotStyle { plot in + plot.background(Color.black.opacity(0.15)) + .cornerRadius(12) + } + .frame(height: 240) + .padding(.horizontal, 20) + + // Legend + HStack(spacing: 18) { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2) + .fill(.white.opacity(0.75)) + .frame(width: 14, height: 10) + Text("Daily synthesis") + } + HStack(spacing: 6) { + Capsule() + .fill(Color.yellow.opacity(0.9)) + .frame(width: 18, height: 2.5) + Text("Body store trend") + } + } + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.65)) } } - .chartPlotStyle { plot in - plot.background(Color.black.opacity(0.15)).cornerRadius(12) + + // MA explanation + if !isLoading { + Text("Trend line uses the 20-day half-life of 25(OH)D — rising means you're consistently building stores, falling means you need more sun.") + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.5)) + .multilineTextAlignment(.center) + .padding(.horizontal, 28) } - .frame(height: 220) - .padding(.horizontal, 20) - } - Spacer() + Spacer(minLength: 20) + } } } .navigationTitle("Vitamin D History") @@ -133,71 +217,79 @@ struct HistoryView: View { .onChange(of: period) { loadData() } } - private var xUnit: Calendar.Component { - switch period { - case .day: return .day - case .week: return .weekOfYear - case .month: return .month - } - } - + // MARK: X-axis format private var xFormat: Date.FormatStyle { switch period { - case .day: return .dateTime.weekday(.abbreviated) - case .week: return .dateTime.month(.abbreviated).day() - case .month: return .dateTime.month(.abbreviated) + case .week: return .dateTime.month(.abbreviated).day() + case .month: return .dateTime.month(.abbreviated).day() + case .threeMonth: return .dateTime.month(.abbreviated) } } + // MARK: Data loading private func loadData() { isLoading = true - healthManager.getVitaminDHistory(days: period.lookbackDays) { dailyTotals in + + // Fetch enough extra history to warm up the MA before the display window. + // 3 × half-life ≈ 60 days of seed data means the MA starts accurate. + let halfLifeDays = 20 + let seedDays = halfLifeDays * 3 + let fetchDays = period.displayDays + seedDays + + healthManager.getVitaminDHistory(days: fetchDays) { dailyTotals in let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) - switch period { - case .day: - self.bars = (0..<7).compactMap { offset -> Bar? in - guard let date = calendar.date(byAdding: .day, value: -offset, to: today) else { return nil } - return Bar(date: date, iu: dailyTotals[date] ?? 0) - }.reversed() - - case .week: - let weekStarts: [Date] = (0..<4).compactMap { offset -> Date? in - guard let anchor = calendar.date(byAdding: .weekOfYear, value: -offset, to: today) else { return nil } - return calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: anchor)) - }.reversed() - - self.bars = weekStarts.map { weekStart in - let weekTotal = dailyTotals.reduce(0.0) { sum, entry in - let entryWeek = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.key)) - return entryWeek == weekStart ? sum + entry.value : sum - } - return Bar(date: weekStart, iu: weekTotal) - } + // Build a complete ordered array for every fetched day (oldest → newest) + let allBars: [Bar] = (0.. Bar? in + let daysAgo = fetchDays - 1 - offset + guard let date = calendar.date(byAdding: .day, value: -daysAgo, to: today) else { return nil } + return Bar(date: date, iu: dailyTotals[date] ?? 0) + } - case .month: - let monthStarts: [Date] = (0..<3).compactMap { offset -> Date? in - guard let anchor = calendar.date(byAdding: .month, value: -offset, to: today) else { return nil } - return calendar.date(from: calendar.dateComponents([.year, .month], from: anchor)) - }.reversed() + // Displayed bars = the most-recent `displayDays` slice + self.bars = Array(allBars.suffix(period.displayDays)) - self.bars = monthStarts.map { monthStart in - let monthTotal = dailyTotals.reduce(0.0) { sum, entry in - let entryMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: entry.key)) - return entryMonth == monthStart ? sum + entry.value : sum - } - return Bar(date: monthStart, iu: monthTotal) - } - } + // Compute MA across ALL fetched days, then take the same suffix + let allMA = Self.halfLifeWMA(allBars: allBars, halfLifeDays: Double(halfLifeDays)) + self.maPoints = Array(allMA.suffix(period.displayDays)) self.isLoading = false } } + // MARK: Half-life weighted moving average + // + // For each day i the weighted average is: + // WMA(i) = Σ_{k=0}^{K} ( iu[i-k] × decay^k ) / Σ_{k=0}^{K} decay^k + // where decay = 2^(−1/halfLife) ≈ 0.9659 for halfLife = 20 days. + // + // Rising = you are outpacing decay (building stores). + // Falling = you are not getting enough sun to maintain levels. + static func halfLifeWMA(allBars: [Bar], halfLifeDays: Double) -> [MAPoint] { + let decay = pow(0.5, 1.0 / halfLifeDays) + var points: [MAPoint] = [] + points.reserveCapacity(allBars.count) + + for i in allBars.indices { + var weightedSum = 0.0 + var totalWeight = 0.0 + let lookback = min(i + 1, Int(ceil(halfLifeDays * 3))) + for offset in 0.. 0 ? weightedSum / totalWeight : 0 + points.append(MAPoint(date: allBars[i].date, iu: wma)) + } + return points + } + + // MARK: Formatting private func formatValue(_ value: Double) -> String { - if value == 0 { return "0" } - if value < 1 { return String(format: "%.1f", value) } + if value == 0 { return "0" } + if value < 1 { return String(format: "%.1f", value) } if value < 1000 { return "\(Int(value))" } let f = NumberFormatter() f.numberStyle = .decimal From 15d69edd5f20d4f0914a6719d550b7a40c76b6e8 Mon Sep 17 00:00:00 2001 From: Jesse Wales <265921002+JWAY21@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:04:12 +1000 Subject: [PATCH 5/5] Fix W period to 7 days (one week), show weekday labels --- Sources/Views/HistoryView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Views/HistoryView.swift b/Sources/Views/HistoryView.swift index 082b46c..02dae7d 100644 --- a/Sources/Views/HistoryView.swift +++ b/Sources/Views/HistoryView.swift @@ -25,7 +25,7 @@ struct HistoryView: View { /// How many daily bars to display var displayDays: Int { switch self { - case .week: return 28 + case .week: return 7 case .month: return 30 case .threeMonth: return 90 } @@ -33,7 +33,7 @@ struct HistoryView: View { var avgLabel: String { switch self { - case .week: return "AVG / DAY · 4 WEEKS" + case .week: return "AVG / DAY · 7 DAYS" case .month: return "AVG / DAY · 30 DAYS" case .threeMonth: return "AVG / DAY · 3 MONTHS" } @@ -42,7 +42,7 @@ struct HistoryView: View { /// Desired number of x-axis labels var axisLabelCount: Int { switch self { - case .week: return 4 + case .week: return 7 case .month: return 5 case .threeMonth: return 4 } @@ -220,7 +220,7 @@ struct HistoryView: View { // MARK: X-axis format private var xFormat: Date.FormatStyle { switch period { - case .week: return .dateTime.month(.abbreviated).day() + case .week: return .dateTime.weekday(.abbreviated) case .month: return .dateTime.month(.abbreviated).day() case .threeMonth: return .dateTime.month(.abbreviated) }