Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions PulseLoop/DesignSystem/ActivityScoreCard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import SwiftUI

/// The day's movement score plus this week's training-load balance, as one compact card.
///
/// Lives on the **Activity** tab rather than Today: Today is already a dense tile grid, and a score
/// that summarises the rest of that tab belongs beside what it summarises. The contributor
/// breakdown is disclosed on tap rather than shown by default, so the card stays one line tall
/// until asked.
struct ActivityScoreCard: View {
let result: ActivityScoreResult
let balance: TrainingLoad.Balance

@State private var expanded = false

var body: some View {
VStack(alignment: .leading, spacing: 14) {
Button { withAnimation(.snappy) { expanded.toggle() } } label: {
HStack(alignment: .center, spacing: 14) {
scoreDial
VStack(alignment: .leading, spacing: 3) {
Text("MOVEMENT")
.font(PulseFont.caption2.weight(.semibold)).tracking(1.0)
.foregroundStyle(PulseColors.textMuted)
Text(result.band.rawValue)
.font(PulseFont.headline)
.foregroundStyle(PulseColors.textPrimary)
Text(loadLine)
.font(PulseFont.caption)
.foregroundStyle(PulseColors.textSecondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 4)
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.font(PulseFont.footnote.weight(.semibold))
.foregroundStyle(PulseColors.textMuted)
}
}
.buttonStyle(.plain)
.accessibilityLabel("Movement score \(result.score) out of 100, \(result.band.rawValue). \(loadLine)")
.accessibilityHint(expanded ? "Hides the breakdown" : "Shows what made up the score")

if expanded {
VStack(spacing: 8) {
ForEach(result.contributors, id: \.kind) { contributor in
contributorRow(contributor)
}
if result.coverage < 1 {
Text(coverageNote)
.font(PulseFont.caption.weight(.regular))
.foregroundStyle(PulseColors.textMuted)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous))
}

private var scoreDial: some View {
ZStack {
Circle()
.stroke(PulseColors.cardSoft, lineWidth: 6)
Circle()
.trim(from: 0, to: max(0.02, Double(result.score) / 100))
.stroke(PulseColors.steps, style: StrokeStyle(lineWidth: 6, lineCap: .round))
.rotationEffect(.degrees(-90))
Text("\(result.score)")
.font(PulseFont.title3.weight(.semibold)).monospacedDigit()
.foregroundStyle(PulseColors.textPrimary)
}
.frame(width: 58, height: 58)
}

private func contributorRow(_ contributor: ActivityContributor) -> some View {
let fraction = contributor.maxPoints > 0 ? contributor.earned / contributor.maxPoints : 0
return HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(contributor.kind.title)
.font(PulseFont.caption.weight(.semibold))
.foregroundStyle(PulseColors.textPrimary)
Text(contributor.detail)
.font(PulseFont.caption2)
.foregroundStyle(PulseColors.textMuted)
}
.frame(maxWidth: .infinity, alignment: .leading)

GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(PulseColors.cardSoft)
Capsule().fill(PulseColors.steps)
.frame(width: max(fraction > 0 ? 4 : 0, geo.size.width * fraction))
}
}
.frame(width: 70, height: 6)

Text("\(Int(contributor.earned.rounded()))/\(Int(contributor.maxPoints))")
.font(PulseFont.caption2.monospacedDigit())
.foregroundStyle(PulseColors.textSecondary)
.frame(width: 42, alignment: .trailing)
}
}

/// One sentence on this week's load. Says what it can't say when history is thin, rather than
/// showing a ratio built on a fortnight and calling it a baseline.
private var loadLine: String {
guard balance.ratio != nil else { return balance.band.detail }
return "Training load · \(balance.band.rawValue.lowercased())"
}

private var coverageNote: String {
"Scored out of \(Int((result.coverage * 100).rounded())) — your ring didn't report everything "
+ "this score can use, so the missing parts were left out rather than counted against you."
}
}
13 changes: 12 additions & 1 deletion PulseLoop/Models/PulseModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,18 @@ final class UserGoal {
var intakeFatG: Int?
var updatedAt: Date

init(id: UUID = UUID(), steps: Int = 10000, sleepMinutes: Int = 480, activeMinutes: Int = 45, workoutsPerWeek: Int = 4, distanceMeters: Double = 8000, calories: Int = 500) {
/// The out-of-the-box targets, named so anything needing a fallback goal reads the same numbers
/// the initializer uses rather than restating them.
static let defaultSteps = 10000
static let defaultSleepMinutes = 480
static let defaultActiveMinutes = 45
static let defaultWorkoutsPerWeek = 4
static let defaultDistanceMeters: Double = 8000
static let defaultCalories = 500

init(id: UUID = UUID(), steps: Int = UserGoal.defaultSteps, sleepMinutes: Int = UserGoal.defaultSleepMinutes,
activeMinutes: Int = UserGoal.defaultActiveMinutes, workoutsPerWeek: Int = UserGoal.defaultWorkoutsPerWeek,
distanceMeters: Double = UserGoal.defaultDistanceMeters, calories: Int = UserGoal.defaultCalories) {
self.id = id
self.steps = steps
self.sleepMinutes = sleepMinutes
Expand Down
179 changes: 179 additions & 0 deletions PulseLoop/Services/ActivityScore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import Foundation

/// One scored signal within a day's activity score, in the same shape the sleep score uses.
struct ActivityContributor: Equatable {
enum Kind: String, CaseIterable {
case steps
case activeMinutes
case energy
case regularity

var title: String {
switch self {
case .steps: return "Steps"
case .activeMinutes: return "Active minutes"
case .energy: return "Active energy"
case .regularity: return "Movement through the day"
}
}

var maxPoints: Double {
switch self {
case .steps: return 35
case .activeMinutes: return 30
case .energy: return 20
case .regularity: return 15
}
}
}

let kind: Kind
let earned: Double
let maxPoints: Double
let detail: String
}

enum ActivityBand: String, CaseIterable {
case restful = "Restful"
case light = "Light"
case active = "Active"
case veryActive = "Very active"

init(score: Int) {
switch score {
case 85...: self = .veryActive
case 70..<85: self = .active
case 45..<70: self = .light
default: self = .restful
}
}
}

struct ActivityScoreResult: Equatable {
let score: Int
let band: ActivityBand
let contributors: [ActivityContributor]
/// Fraction of the full 100-point picture this rested on.
let coverage: Double
let algorithmVersion: Int
}

/// Everything the score reads about one day. A plain struct so the maths stays pure and testable —
/// `ActivityScoreService` does the fetching.
struct ActivityScoreInputs: Equatable {
let steps: Int?
let activeMinutes: Int?
let activeEnergyKcal: Double?
/// Hours between `regularityWindow` that contained at least `regularityStepFloor` steps, and how
/// many hours were observable at all. nil when the ring reports no intraday buckets.
let activeHours: Int?
let observableHours: Int?

let stepsGoal: Int
let activeMinutesGoal: Int
let energyGoal: Int
}

/// A daily movement score, 0–100 — PulseLoop's equivalent of Oura's Activity Score or Ultrahuman's
/// Movement Index.
///
/// Every contributor is measured **against the user's own goals**, not a population target, because
/// the goals already exist and are already editable. A score built on a fixed 10,000 steps would be
/// telling a marathoner and a recovering patient the same thing.
///
/// Missing signals leave the denominator rather than scoring zero — the rule the sleep score and
/// readiness both follow, and what lets one number mean the same thing on a ring that reports
/// intraday buckets and one that doesn't.
enum ActivityScore {
static let algorithmVersion = 1

/// A score is only produced when at least this many points were available.
static let minAvailablePoints: Double = 50

/// Steps within an hour that count it as an "active" hour. Matches the widely-used 250-per-hour
/// convention (Apple's stand-hour analogue) rather than inventing a threshold.
static let regularityStepFloor = 250

/// The window regularity is judged over: 08:00–22:00 local. Hours outside it are not counted
/// against you, since nobody should be scored for not walking at 4 a.m.
static let regularityWindow = 8..<22

/// Goal progress → points.
///
/// Full marks at goal, 65 % at 60 % of goal, linear to zero below that. **Exceeding a goal is
/// never penalised**: overreaching is what training load is for, and a movement score that
/// docked you for a long hike would be actively misleading.
static func goalScore(actual: Double, goal: Double, points: Double) -> Double {
guard goal > 0, actual.isFinite, actual >= 0 else { return 0 }
let fraction = actual / goal
if fraction >= 1 { return points }
if fraction >= 0.6 {
return points * (0.65 + 0.35 * ((fraction - 0.6) / 0.4))
}
return points * 0.65 * (fraction / 0.6)
}

static func calculate(_ input: ActivityScoreInputs) -> ActivityScoreResult {
var contributors: [ActivityContributor] = []

if let steps = input.steps {
contributors.append(ActivityContributor(
kind: .steps,
earned: goalScore(actual: Double(steps), goal: Double(input.stepsGoal),
points: ActivityContributor.Kind.steps.maxPoints),
maxPoints: ActivityContributor.Kind.steps.maxPoints,
detail: "\(steps) of \(input.stepsGoal)"
))
}

if let active = input.activeMinutes {
contributors.append(ActivityContributor(
kind: .activeMinutes,
earned: goalScore(actual: Double(active), goal: Double(input.activeMinutesGoal),
points: ActivityContributor.Kind.activeMinutes.maxPoints),
maxPoints: ActivityContributor.Kind.activeMinutes.maxPoints,
detail: "\(active) of \(input.activeMinutesGoal) min"
))
}

// Ring-reported calories are unverified on the history path, so `ActivityDaily.calories` is
// nil for ring-history days — which correctly drops this contributor rather than scoring a
// number the app doesn't stand behind.
if let energy = input.activeEnergyKcal {
contributors.append(ActivityContributor(
kind: .energy,
earned: goalScore(actual: energy, goal: Double(input.energyGoal),
points: ActivityContributor.Kind.energy.maxPoints),
maxPoints: ActivityContributor.Kind.energy.maxPoints,
detail: "\(Int(energy.rounded())) of \(input.energyGoal) kcal"
))
}

// Regularity: a day that hits its step goal in one gym session and then sits for twelve
// hours is a different day from one that moves throughout, and only this contributor can
// tell them apart.
if let activeHours = input.activeHours, let observable = input.observableHours, observable > 0 {
contributors.append(ActivityContributor(
kind: .regularity,
earned: goalScore(actual: Double(activeHours), goal: Double(observable),
points: ActivityContributor.Kind.regularity.maxPoints),
maxPoints: ActivityContributor.Kind.regularity.maxPoints,
detail: "\(activeHours) of \(observable) hours with movement"
))
}

let available = contributors.reduce(0) { $0 + $1.maxPoints }
let earned = contributors.reduce(0) { $0 + $1.earned }
let score = available >= minAvailablePoints
? Int(min(100, max(0, (earned / available * 100).rounded())))
: 0

return ActivityScoreResult(
score: score,
band: ActivityBand(score: score),
contributors: contributors,
coverage: available / 100,
algorithmVersion: algorithmVersion
)
}
}
Loading