feat(activity): add an activity calendar with personal baseline and goals

A week or month of steps, active minutes, workouts by sport and training
load, each day placed against the person's own spread over the 84 days
before the period and each period against the median of the twelve weeks
or six months before it. Optional weekly and monthly goals live on the
account (activity_goals_json), travel with the backup and are cleared by
a data wipe. The calendar sits on the workouts page; the steps page links
to it. Computed server-side without AI.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 21:38:32 +02:00
1 parent e1b70537cc
commit 5eb7caebae
40 files changed
+4172 -6

No files matched your search

+572 -4
View File
@@ -5023,6 +5023,89 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/activity/calendar:
get:
tags:
- Measurements
summary: Activity calendar against the person's own normal
description: "A week (Monday to Sunday) or a month of activity, computed server-side without AI: per local day the step
total (the canonical-source sum the steps chart draws), active minutes (the larger of exercise minutes and
workout minutes), workouts by sport after the cross-source collapse, and a training-load score when one exists.
Each day is placed against the person's own spread over the 84 days before the period; the period total against
the median of the twelve weeks or six months before it, pro-rated while the period runs. Never a population
norm. Weekly goals are reported per calendar row, the monthly step goal for a month. Owned by the Workouts
module."
parameters:
- in: query
name: period
schema:
default: month
description: "Calendar span: one week (Monday to Sunday) or one month."
type: string
enum:
- week
- month
description: "Calendar span: one week (Monday to Sunday) or one month."
- in: query
name: date
schema:
description: Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after
today, or more than ten years back, is refused.
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
description: Any day inside the period to show (`YYYY-MM-DD`, the person's local day). Defaults to today. A day after
today, or more than ten years back, is refused.
responses:
"200":
description: The calendar.
content:
application/json:
schema:
$ref: "#/components/schemas/ActivityCalendarEnvelope"
"401": *a1
"403": *a13
"422": *a4
"429": *a5
/api/activity/goals:
get:
tags:
- Measurements
summary: Activity goals
description: "The person's own activity goals: workouts, active minutes and steps per week, steps per month. `null` =
not set; there is no default. Owner only; a delegate reads the goals inside the calendar."
responses:
"200":
description: The goals.
content:
application/json:
schema:
$ref: "#/components/schemas/ActivityGoalsEnvelope"
"401": *a1
"422": *a4
"429": *a5
patch:
tags:
- Measurements
summary: Set activity goals
description: "Sets the goals the body names and keeps the rest; `null` clears a goal. Owner only: a shared or delegated
session cannot change another person's goals."
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ActivityGoalsPatch"
responses:
"200":
description: The goals after the change.
content:
application/json:
schema:
$ref: "#/components/schemas/ActivityGoalsPatchEnvelope"
"401": *a1
"422": *a4
"429": *a12
/api/devices:
post:
tags:
@@ -27531,6 +27614,41 @@ components:
speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor
workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points.
Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only)."
ActivityGoalsPatch:
type: object
properties:
workoutsPerWeek:
description: Workouts per week (Monday to Sunday). `null` clears the goal.
anyOf:
- type: integer
minimum: 1
maximum: 28
- type: "null"
activeMinutesPerWeek:
description: "Active minutes per week: the larger of the day's exercise minutes and the day's workout minutes, summed
over the week. `null` clears the goal."
anyOf:
- type: integer
minimum: 10
maximum: 5000
- type: "null"
stepsPerWeek:
description: Steps per week. `null` clears the goal.
anyOf:
- type: integer
minimum: 1000
maximum: 700000
- type: "null"
stepsPerMonth:
description: Steps per calendar month. `null` clears the goal.
anyOf:
- type: integer
minimum: 1000
maximum: 3000000
- type: "null"
additionalProperties: false
description: The goals to change. A key that is present replaces the stored goal (`null` clears it); a key that is
absent stays as it is. Unknown keys are refused.
DeviceRegisterRequest:
type: object
properties:
@@ -36469,10 +36587,11 @@ components:
one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`,
`upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each
`/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a
credential of its own carries the generic `http.unauthenticated`. ai — ai.budget.exceeded,
ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling,
ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider
— ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut,
credential of its own carries the generic `http.unauthenticated`. activity —
activity.calendar.future_date, activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none,
ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling,
ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid,
ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut,
aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis —
anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited,
anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue,
@@ -39082,6 +39201,7 @@ components:
documentsLayoutJson: {}
moduleListLayoutJson: {}
previewFeaturesJson: {}
activityGoalsJson: {}
reportSelectionJson: {}
globalExcludedInjectionSites:
type: array
@@ -49122,6 +49242,454 @@ components:
- index
- status
additionalProperties: false
ActivityCalendarEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/ActivityCalendar"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
ActivityCalendar:
type: object
properties:
period:
type: string
enum:
- week
- month
start:
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
end:
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
gridStart:
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
description: First day drawn (the Monday on or before `start`).
gridEnd:
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
description: Last day drawn (the Sunday on or after `end`).
today:
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
timezone:
type: string
description: The IANA zone the days are cut in.
loadType:
anyOf:
- type: string
enum:
- DAY_STRAIN
- CARDIO_LOAD
- STRAIN_SCORE
- type: "null"
description: Which training-load score `load` carries; null when the account has none.
days:
type: array
items:
type: object
properties:
date:
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
inPeriod:
type: boolean
future:
type: boolean
steps:
type:
- number
- "null"
activeMinutes:
description: The larger of the day's exercise minutes and workout minutes (never their sum).
type:
- number
- "null"
workoutCount:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
workoutsBySport:
type: object
propertyNames:
type: string
additionalProperties:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
load:
type:
- number
- "null"
level:
type: object
properties:
steps:
type: string
enum:
- none
- unrated
- low
- below
- usual
- above
- high
description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th
percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual`
between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with."
activeMinutes:
type: string
enum:
- none
- unrated
- low
- below
- usual
- above
- high
description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th
percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual`
between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with."
load:
type: string
enum:
- none
- unrated
- low
- below
- usual
- above
- high
description: "Where the day sits in the person's own spread over the 84 days before the period: `low` below their 10th
percentile, `below` below the 25th, `above` above the 75th, `high` above the 90th, `usual`
between. `none` = no value that day; `unrated` = fewer than 14 days of history to compare with."
required:
- steps
- activeMinutes
- load
additionalProperties: false
required:
- date
- inPeriod
- future
- steps
- activeMinutes
- workoutCount
- workoutsBySport
- load
- level
additionalProperties: false
weeks:
type: array
items:
type: object
properties:
start:
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
end:
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
steps:
type:
- number
- "null"
activeMinutes:
type:
- number
- "null"
workouts:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
goals:
type: array
items:
$ref: "#/components/schemas/ActivityGoalProgress"
description: The weekly goals for a week that has begun.
required:
- start
- end
- steps
- activeMinutes
- workouts
- goals
additionalProperties: false
baseline:
type: object
properties:
steps:
$ref: "#/components/schemas/ActivityBaselineStats"
activeMinutes:
$ref: "#/components/schemas/ActivityBaselineStats"
load:
$ref: "#/components/schemas/ActivityBaselineStats"
required:
- steps
- activeMinutes
- load
additionalProperties: false
summary:
type: object
properties:
steps:
$ref: "#/components/schemas/ActivityPeriodComparison"
activeMinutes:
$ref: "#/components/schemas/ActivityPeriodComparison"
workouts:
$ref: "#/components/schemas/ActivityPeriodComparison"
load:
$ref: "#/components/schemas/ActivityPeriodComparison"
required:
- steps
- activeMinutes
- workouts
- load
additionalProperties: false
sports:
type: array
items:
type: object
properties:
sportType:
type: string
count:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- sportType
- count
additionalProperties: false
goals:
$ref: "#/components/schemas/ActivityGoals"
goalProgress:
type: array
items:
$ref: "#/components/schemas/ActivityGoalProgress"
available:
type: object
properties:
steps:
type: boolean
activeMinutes:
type: boolean
workouts:
type: boolean
load:
type: boolean
required:
- steps
- activeMinutes
- workouts
- load
additionalProperties: false
previous:
type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
description: A day in the previous period.
next:
anyOf:
- type: string
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
- type: "null"
description: A day in the next period, or null when that lies after today.
required:
- period
- start
- end
- gridStart
- gridEnd
- today
- timezone
- loadType
- days
- weeks
- baseline
- summary
- sports
- goals
- goalProgress
- available
- previous
- next
additionalProperties: false
ActivityGoalProgress:
type: object
properties:
key:
type: string
enum:
- workoutsPerWeek
- activeMinutesPerWeek
- stepsPerWeek
- stepsPerMonth
target:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
value:
type: number
met:
type: boolean
required:
- key
- target
- value
- met
additionalProperties: false
ActivityBaselineStats:
anyOf:
- type: object
properties:
median:
type: number
p10:
type: number
p25:
type: number
p75:
type: number
p90:
type: number
days:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Days with a value the spread was computed from.
required:
- median
- p10
- p25
- p75
- p90
- days
additionalProperties: false
- type: "null"
ActivityPeriodComparison:
type: object
properties:
value:
description: "The period so far: a total (steps, active minutes, workouts) or a mean (load)."
type:
- number
- "null"
daysWithData:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
usual:
description: "The person's usual full period: the median of the twelve weeks (or, per day, of the six months) before it.
Null with fewer than three earlier periods of data."
type:
- number
- "null"
usualToDate:
description: The usual figure for the days elapsed so far; equals `usual` once the period is over.
type:
- number
- "null"
referencePeriods:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- value
- daysWithData
- usual
- usualToDate
- referencePeriods
additionalProperties: false
ActivityGoals:
type: object
properties:
workoutsPerWeek:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
activeMinutesPerWeek:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
stepsPerWeek:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
stepsPerMonth:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
required:
- workoutsPerWeek
- activeMinutesPerWeek
- stepsPerWeek
- stepsPerMonth
additionalProperties: false
description: The person's own goals; `null` = not set. There is no default goal.
ActivityGoalsEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/ActivityGoals"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
ActivityGoalsPatchEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/ActivityGoals"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
DeviceRegisterResponse:
type: object
properties: