mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
e1b70537cc
commit
5eb7caebae
40 files changed
+4172
-6
No files matched your search
+572
-4
@@ -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:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* The activity calendar on the workouts page (D1036), at phone width: the
|
||||
* card renders a whole-week grid, pages back a period, switches to the week
|
||||
* view, and the goals sheet saves a goal the calendar then reports.
|
||||
*/
|
||||
test.describe("activity calendar", () => {
|
||||
test.use({
|
||||
storageState: STORAGE_STATE_PATH,
|
||||
viewport: { width: 390, height: 844 },
|
||||
});
|
||||
|
||||
test("draws the month, pages back and switches to a week", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/insights/workouts");
|
||||
const card = page.locator('[data-slot="activity-calendar"]');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="activity-calendar-label"]'),
|
||||
).not.toHaveText("");
|
||||
|
||||
const days = page.locator('[data-slot="activity-calendar-day"]');
|
||||
const monthCount = await days.count();
|
||||
if (monthCount > 0) expect(monthCount % 7).toBe(0);
|
||||
|
||||
const label = page.locator('[data-slot="activity-calendar-label"]');
|
||||
const before = await label.textContent();
|
||||
await page.locator('[data-slot="activity-calendar-previous"]').click();
|
||||
await expect(label).not.toHaveText(before ?? "");
|
||||
|
||||
await card.getByRole("radio", { name: "Week" }).click();
|
||||
if ((await days.count()) > 0) await expect(days).toHaveCount(7);
|
||||
|
||||
// Nothing in the card pushes the page sideways on a phone.
|
||||
const overflow = await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth -
|
||||
document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("saves a goal and shows its progress", async ({ page }) => {
|
||||
await page.goto("/insights/workouts");
|
||||
await page.locator('[data-slot="activity-calendar-set-goals"]').click();
|
||||
await page.getByLabel("Steps per month").fill("150000");
|
||||
await page.locator('[data-slot="activity-goals-save"]').click();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="activity-calendar-goals"]')
|
||||
.getByText("Steps per month"),
|
||||
).toBeVisible();
|
||||
|
||||
// Clear it again so the account is left as it was found.
|
||||
await page.locator('[data-slot="activity-calendar-set-goals"]').click();
|
||||
await page.getByLabel("Steps per month").fill("");
|
||||
await page.locator('[data-slot="activity-goals-save"]').click();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="activity-calendar-goals"]')
|
||||
.getByText("Steps per month"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -13471,5 +13471,73 @@
|
||||
"clinicalUpper": "Klinische Obergrenze",
|
||||
"clinicalLower": "Klinische Untergrenze"
|
||||
}
|
||||
},
|
||||
"activityCalendar": {
|
||||
"title": "Aktivitätskalender",
|
||||
"periodLabel": "Zeitraum",
|
||||
"period": {
|
||||
"week": "Woche",
|
||||
"month": "Monat"
|
||||
},
|
||||
"weekRange": "{start} bis {end}",
|
||||
"previous": "Vorheriger Zeitraum",
|
||||
"next": "Nächster Zeitraum",
|
||||
"metricLabel": "Tage einfärben nach",
|
||||
"metric": {
|
||||
"steps": "Schritte",
|
||||
"activeMinutes": "Aktive Minuten",
|
||||
"load": "Trainingslast"
|
||||
},
|
||||
"empty": "In diesem Zeitraum gibt es noch keine Schritte, aktiven Minuten oder Trainings.",
|
||||
"loadError": "Der Aktivitätskalender konnte nicht geladen werden.",
|
||||
"weekGoalsColumn": "Wochenziele",
|
||||
"weekGoalsMet": "Alle Wochenziele erreicht",
|
||||
"weekGoalsNotMet": "Nicht alle Wochenziele erreicht",
|
||||
"cell": {
|
||||
"steps": "{value} Schritte",
|
||||
"activeMinutes": "{value} aktive Minuten",
|
||||
"workouts": "{value} Trainings: {sports}"
|
||||
},
|
||||
"level": {
|
||||
"none": "Keine Daten",
|
||||
"unrated": "Noch zu wenig Verlauf",
|
||||
"low": "Weit unter deinem Üblichen",
|
||||
"below": "Unter deinem Üblichen",
|
||||
"usual": "Etwa wie üblich",
|
||||
"above": "Über deinem Üblichen",
|
||||
"high": "Weit über deinem Üblichen"
|
||||
},
|
||||
"legend": "Verglichen mit deinen 12 Wochen davor:",
|
||||
"noBaseline": "Dein üblicher Bereich erscheint nach 14 Tagen mit Daten.",
|
||||
"summary": {
|
||||
"steps": "{value} Schritte",
|
||||
"activeMinutes": "{value} aktive Minuten",
|
||||
"workouts": "{value} Trainings",
|
||||
"load": "Durchschnittliche Trainingslast {value}",
|
||||
"usual": "Bis hierher üblich bei dir: {value}",
|
||||
"noUsual": "Noch zu wenige frühere Wochen oder Monate zum Vergleichen."
|
||||
},
|
||||
"sportCount": "{sport}: {count}",
|
||||
"goal": {
|
||||
"workoutsPerWeek": "Trainings pro Woche",
|
||||
"activeMinutesPerWeek": "Aktive Minuten pro Woche",
|
||||
"stepsPerWeek": "Schritte pro Woche",
|
||||
"stepsPerMonth": "Schritte pro Monat"
|
||||
},
|
||||
"goalOf": "{value} von {target}",
|
||||
"setGoals": "Ziele festlegen",
|
||||
"editGoals": "Ziele ändern",
|
||||
"goalsSheet": {
|
||||
"title": "Aktivitätsziele",
|
||||
"description": "Lass ein Feld leer, wenn du dort kein Ziel möchtest.",
|
||||
"none": "Kein Ziel",
|
||||
"invalid": "Eine ganze Zahl von {min} bis {max}.",
|
||||
"saveError": "Die Ziele konnten nicht gespeichert werden."
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Aktivitätskalender",
|
||||
"body": "Deine Schritte als Woche oder Monat, verglichen mit deinem Üblichen.",
|
||||
"cta": "Kalender öffnen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13471,5 +13471,73 @@
|
||||
"clinicalUpper": "Clinical upper limit",
|
||||
"clinicalLower": "Clinical lower limit"
|
||||
}
|
||||
},
|
||||
"activityCalendar": {
|
||||
"title": "Activity calendar",
|
||||
"periodLabel": "Calendar span",
|
||||
"period": {
|
||||
"week": "Week",
|
||||
"month": "Month"
|
||||
},
|
||||
"weekRange": "{start} to {end}",
|
||||
"previous": "Previous period",
|
||||
"next": "Next period",
|
||||
"metricLabel": "Colour the days by",
|
||||
"metric": {
|
||||
"steps": "Steps",
|
||||
"activeMinutes": "Active minutes",
|
||||
"load": "Training load"
|
||||
},
|
||||
"empty": "No steps, active minutes or workouts in this period yet.",
|
||||
"loadError": "The activity calendar could not be loaded.",
|
||||
"weekGoalsColumn": "Weekly goals",
|
||||
"weekGoalsMet": "All weekly goals met",
|
||||
"weekGoalsNotMet": "Not all weekly goals met",
|
||||
"cell": {
|
||||
"steps": "{value} steps",
|
||||
"activeMinutes": "{value} active minutes",
|
||||
"workouts": "{value} workouts: {sports}"
|
||||
},
|
||||
"level": {
|
||||
"none": "No data",
|
||||
"unrated": "Not enough history yet",
|
||||
"low": "Far below your usual",
|
||||
"below": "Below your usual",
|
||||
"usual": "About your usual",
|
||||
"above": "Above your usual",
|
||||
"high": "Far above your usual"
|
||||
},
|
||||
"legend": "Compared with your 12 weeks before:",
|
||||
"noBaseline": "Your usual range shows after 14 days with data.",
|
||||
"summary": {
|
||||
"steps": "{value} steps",
|
||||
"activeMinutes": "{value} active minutes",
|
||||
"workouts": "{value} workouts",
|
||||
"load": "Average training load {value}",
|
||||
"usual": "Your usual by this point: {value}",
|
||||
"noUsual": "Not enough earlier weeks or months to compare with yet."
|
||||
},
|
||||
"sportCount": "{sport}: {count}",
|
||||
"goal": {
|
||||
"workoutsPerWeek": "Workouts per week",
|
||||
"activeMinutesPerWeek": "Active minutes per week",
|
||||
"stepsPerWeek": "Steps per week",
|
||||
"stepsPerMonth": "Steps per month"
|
||||
},
|
||||
"goalOf": "{value} of {target}",
|
||||
"setGoals": "Set goals",
|
||||
"editGoals": "Change goals",
|
||||
"goalsSheet": {
|
||||
"title": "Activity goals",
|
||||
"description": "Leave a field empty for no goal.",
|
||||
"none": "No goal",
|
||||
"invalid": "A whole number from {min} to {max}.",
|
||||
"saveError": "The goals could not be saved."
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Activity calendar",
|
||||
"body": "Your steps as a week or a month, against your own usual.",
|
||||
"cta": "Open calendar"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13471,5 +13471,73 @@
|
||||
"clinicalUpper": "Límite clínico superior",
|
||||
"clinicalLower": "Límite clínico inferior"
|
||||
}
|
||||
},
|
||||
"activityCalendar": {
|
||||
"title": "Calendario de actividad",
|
||||
"periodLabel": "Periodo del calendario",
|
||||
"period": {
|
||||
"week": "Semana",
|
||||
"month": "Mes"
|
||||
},
|
||||
"weekRange": "{start} a {end}",
|
||||
"previous": "Periodo anterior",
|
||||
"next": "Periodo siguiente",
|
||||
"metricLabel": "Colorear los días por",
|
||||
"metric": {
|
||||
"steps": "Pasos",
|
||||
"activeMinutes": "Minutos activos",
|
||||
"load": "Carga de entrenamiento"
|
||||
},
|
||||
"empty": "Todavía no hay pasos, minutos activos ni entrenamientos en este periodo.",
|
||||
"loadError": "No se pudo cargar el calendario de actividad.",
|
||||
"weekGoalsColumn": "Objetivos semanales",
|
||||
"weekGoalsMet": "Todos los objetivos semanales cumplidos",
|
||||
"weekGoalsNotMet": "No se cumplieron todos los objetivos semanales",
|
||||
"cell": {
|
||||
"steps": "{value} pasos",
|
||||
"activeMinutes": "{value} minutos activos",
|
||||
"workouts": "{value} entrenamientos: {sports}"
|
||||
},
|
||||
"level": {
|
||||
"none": "Sin datos",
|
||||
"unrated": "Aún no hay suficiente historial",
|
||||
"low": "Muy por debajo de lo habitual",
|
||||
"below": "Por debajo de lo habitual",
|
||||
"usual": "Como de costumbre",
|
||||
"above": "Por encima de lo habitual",
|
||||
"high": "Muy por encima de lo habitual"
|
||||
},
|
||||
"legend": "Comparado con tus 12 semanas anteriores:",
|
||||
"noBaseline": "Tu rango habitual aparece tras 14 días con datos.",
|
||||
"summary": {
|
||||
"steps": "{value} pasos",
|
||||
"activeMinutes": "{value} minutos activos",
|
||||
"workouts": "{value} entrenamientos",
|
||||
"load": "Carga de entrenamiento media {value}",
|
||||
"usual": "Lo habitual hasta este punto: {value}",
|
||||
"noUsual": "Aún no hay suficientes semanas o meses anteriores para comparar."
|
||||
},
|
||||
"sportCount": "{sport}: {count}",
|
||||
"goal": {
|
||||
"workoutsPerWeek": "Entrenamientos por semana",
|
||||
"activeMinutesPerWeek": "Minutos activos por semana",
|
||||
"stepsPerWeek": "Pasos por semana",
|
||||
"stepsPerMonth": "Pasos por mes"
|
||||
},
|
||||
"goalOf": "{value} de {target}",
|
||||
"setGoals": "Fijar objetivos",
|
||||
"editGoals": "Cambiar objetivos",
|
||||
"goalsSheet": {
|
||||
"title": "Objetivos de actividad",
|
||||
"description": "Deja un campo vacío si no quieres un objetivo.",
|
||||
"none": "Sin objetivo",
|
||||
"invalid": "Un número entero de {min} a {max}.",
|
||||
"saveError": "No se pudieron guardar los objetivos."
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Calendario de actividad",
|
||||
"body": "Tus pasos por semana o por mes, frente a lo que es habitual para ti.",
|
||||
"cta": "Abrir calendario"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13471,5 +13471,73 @@
|
||||
"clinicalUpper": "Limite clinique haute",
|
||||
"clinicalLower": "Limite clinique basse"
|
||||
}
|
||||
},
|
||||
"activityCalendar": {
|
||||
"title": "Calendrier d'activité",
|
||||
"periodLabel": "Période du calendrier",
|
||||
"period": {
|
||||
"week": "Semaine",
|
||||
"month": "Mois"
|
||||
},
|
||||
"weekRange": "{start} au {end}",
|
||||
"previous": "Période précédente",
|
||||
"next": "Période suivante",
|
||||
"metricLabel": "Colorer les jours selon",
|
||||
"metric": {
|
||||
"steps": "Pas",
|
||||
"activeMinutes": "Minutes actives",
|
||||
"load": "Charge d'entraînement"
|
||||
},
|
||||
"empty": "Aucun pas, aucune minute active ni aucun entraînement sur cette période pour l'instant.",
|
||||
"loadError": "Le calendrier d'activité n'a pas pu être chargé.",
|
||||
"weekGoalsColumn": "Objectifs hebdomadaires",
|
||||
"weekGoalsMet": "Tous les objectifs hebdomadaires atteints",
|
||||
"weekGoalsNotMet": "Objectifs hebdomadaires pas tous atteints",
|
||||
"cell": {
|
||||
"steps": "{value} pas",
|
||||
"activeMinutes": "{value} minutes actives",
|
||||
"workouts": "{value} entraînements : {sports}"
|
||||
},
|
||||
"level": {
|
||||
"none": "Aucune donnée",
|
||||
"unrated": "Pas encore assez d'historique",
|
||||
"low": "Bien en dessous de ton habitude",
|
||||
"below": "En dessous de ton habitude",
|
||||
"usual": "Comme d'habitude",
|
||||
"above": "Au-dessus de ton habitude",
|
||||
"high": "Bien au-dessus de ton habitude"
|
||||
},
|
||||
"legend": "Comparé à tes 12 semaines précédentes :",
|
||||
"noBaseline": "Ta plage habituelle apparaît après 14 jours avec des données.",
|
||||
"summary": {
|
||||
"steps": "{value} pas",
|
||||
"activeMinutes": "{value} minutes actives",
|
||||
"workouts": "{value} entraînements",
|
||||
"load": "Charge d'entraînement moyenne {value}",
|
||||
"usual": "Ton habitude à ce stade : {value}",
|
||||
"noUsual": "Pas encore assez de semaines ou de mois précédents pour comparer."
|
||||
},
|
||||
"sportCount": "{sport} : {count}",
|
||||
"goal": {
|
||||
"workoutsPerWeek": "Entraînements par semaine",
|
||||
"activeMinutesPerWeek": "Minutes actives par semaine",
|
||||
"stepsPerWeek": "Pas par semaine",
|
||||
"stepsPerMonth": "Pas par mois"
|
||||
},
|
||||
"goalOf": "{value} sur {target}",
|
||||
"setGoals": "Définir des objectifs",
|
||||
"editGoals": "Modifier les objectifs",
|
||||
"goalsSheet": {
|
||||
"title": "Objectifs d'activité",
|
||||
"description": "Laisse un champ vide pour ne pas fixer d'objectif.",
|
||||
"none": "Aucun objectif",
|
||||
"invalid": "Un nombre entier de {min} à {max}.",
|
||||
"saveError": "Les objectifs n'ont pas pu être enregistrés."
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Calendrier d'activité",
|
||||
"body": "Tes pas par semaine ou par mois, comparés à ton habitude.",
|
||||
"cta": "Ouvrir le calendrier"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13471,5 +13471,73 @@
|
||||
"clinicalUpper": "Limite clinico superiore",
|
||||
"clinicalLower": "Limite clinico inferiore"
|
||||
}
|
||||
},
|
||||
"activityCalendar": {
|
||||
"title": "Calendario attività",
|
||||
"periodLabel": "Periodo del calendario",
|
||||
"period": {
|
||||
"week": "Settimana",
|
||||
"month": "Mese"
|
||||
},
|
||||
"weekRange": "dal {start} al {end}",
|
||||
"previous": "Periodo precedente",
|
||||
"next": "Periodo successivo",
|
||||
"metricLabel": "Colora i giorni per",
|
||||
"metric": {
|
||||
"steps": "Passi",
|
||||
"activeMinutes": "Minuti attivi",
|
||||
"load": "Carico di allenamento"
|
||||
},
|
||||
"empty": "Ancora nessun passo, minuto attivo o allenamento in questo periodo.",
|
||||
"loadError": "Impossibile caricare il calendario attività.",
|
||||
"weekGoalsColumn": "Obiettivi settimanali",
|
||||
"weekGoalsMet": "Tutti gli obiettivi settimanali raggiunti",
|
||||
"weekGoalsNotMet": "Non tutti gli obiettivi settimanali raggiunti",
|
||||
"cell": {
|
||||
"steps": "{value} passi",
|
||||
"activeMinutes": "{value} minuti attivi",
|
||||
"workouts": "{value} allenamenti: {sports}"
|
||||
},
|
||||
"level": {
|
||||
"none": "Nessun dato",
|
||||
"unrated": "Storico ancora insufficiente",
|
||||
"low": "Molto sotto il tuo solito",
|
||||
"below": "Sotto il tuo solito",
|
||||
"usual": "Come al solito",
|
||||
"above": "Sopra il tuo solito",
|
||||
"high": "Molto sopra il tuo solito"
|
||||
},
|
||||
"legend": "Rispetto alle tue 12 settimane precedenti:",
|
||||
"noBaseline": "Il tuo intervallo abituale compare dopo 14 giorni con dati.",
|
||||
"summary": {
|
||||
"steps": "{value} passi",
|
||||
"activeMinutes": "{value} minuti attivi",
|
||||
"workouts": "{value} allenamenti",
|
||||
"load": "Carico di allenamento medio {value}",
|
||||
"usual": "Il tuo solito fino a questo punto: {value}",
|
||||
"noUsual": "Non ci sono ancora abbastanza settimane o mesi precedenti per un confronto."
|
||||
},
|
||||
"sportCount": "{sport}: {count}",
|
||||
"goal": {
|
||||
"workoutsPerWeek": "Allenamenti a settimana",
|
||||
"activeMinutesPerWeek": "Minuti attivi a settimana",
|
||||
"stepsPerWeek": "Passi a settimana",
|
||||
"stepsPerMonth": "Passi al mese"
|
||||
},
|
||||
"goalOf": "{value} di {target}",
|
||||
"setGoals": "Imposta obiettivi",
|
||||
"editGoals": "Modifica obiettivi",
|
||||
"goalsSheet": {
|
||||
"title": "Obiettivi di attività",
|
||||
"description": "Lascia vuoto un campo per non avere un obiettivo.",
|
||||
"none": "Nessun obiettivo",
|
||||
"invalid": "Un numero intero da {min} a {max}.",
|
||||
"saveError": "Impossibile salvare gli obiettivi."
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Calendario attività",
|
||||
"body": "I tuoi passi per settimana o per mese, rispetto al tuo solito.",
|
||||
"cta": "Apri il calendario"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13471,5 +13471,73 @@
|
||||
"clinicalUpper": "임상 기준 상한",
|
||||
"clinicalLower": "임상 기준 하한"
|
||||
}
|
||||
},
|
||||
"activityCalendar": {
|
||||
"title": "활동 캘린더",
|
||||
"periodLabel": "캘린더 기간",
|
||||
"period": {
|
||||
"week": "주",
|
||||
"month": "월"
|
||||
},
|
||||
"weekRange": "{start} ~ {end}",
|
||||
"previous": "이전 기간",
|
||||
"next": "다음 기간",
|
||||
"metricLabel": "날짜 색상 기준",
|
||||
"metric": {
|
||||
"steps": "걸음 수",
|
||||
"activeMinutes": "활동 시간(분)",
|
||||
"load": "운동 부하"
|
||||
},
|
||||
"empty": "이 기간에는 아직 걸음 수, 활동 시간, 운동 기록이 없습니다.",
|
||||
"loadError": "활동 캘린더를 불러오지 못했습니다.",
|
||||
"weekGoalsColumn": "주간 목표",
|
||||
"weekGoalsMet": "주간 목표를 모두 달성함",
|
||||
"weekGoalsNotMet": "주간 목표를 모두 달성하지는 못함",
|
||||
"cell": {
|
||||
"steps": "{value}걸음",
|
||||
"activeMinutes": "활동 {value}분",
|
||||
"workouts": "운동 {value}회: {sports}"
|
||||
},
|
||||
"level": {
|
||||
"none": "데이터 없음",
|
||||
"unrated": "아직 기록이 충분하지 않음",
|
||||
"low": "평소보다 훨씬 적음",
|
||||
"below": "평소보다 적음",
|
||||
"usual": "평소와 비슷함",
|
||||
"above": "평소보다 많음",
|
||||
"high": "평소보다 훨씬 많음"
|
||||
},
|
||||
"legend": "이전 12주와 비교:",
|
||||
"noBaseline": "데이터가 있는 날이 14일이 되면 평소 범위가 표시됩니다.",
|
||||
"summary": {
|
||||
"steps": "{value}걸음",
|
||||
"activeMinutes": "활동 {value}분",
|
||||
"workouts": "운동 {value}회",
|
||||
"load": "평균 운동 부하 {value}",
|
||||
"usual": "이 시점까지의 평소 값: {value}",
|
||||
"noUsual": "비교할 이전 주나 달이 아직 충분하지 않습니다."
|
||||
},
|
||||
"sportCount": "{sport}: {count}",
|
||||
"goal": {
|
||||
"workoutsPerWeek": "주당 운동 횟수",
|
||||
"activeMinutesPerWeek": "주당 활동 시간(분)",
|
||||
"stepsPerWeek": "주당 걸음 수",
|
||||
"stepsPerMonth": "월간 걸음 수"
|
||||
},
|
||||
"goalOf": "{target} 중 {value}",
|
||||
"setGoals": "목표 설정",
|
||||
"editGoals": "목표 변경",
|
||||
"goalsSheet": {
|
||||
"title": "활동 목표",
|
||||
"description": "목표를 두지 않으려면 칸을 비워 두세요.",
|
||||
"none": "목표 없음",
|
||||
"invalid": "{min}부터 {max}까지의 정수를 입력하세요.",
|
||||
"saveError": "목표를 저장하지 못했습니다."
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "활동 캘린더",
|
||||
"body": "주 또는 월 단위의 걸음 수를 내 평소 값과 비교합니다.",
|
||||
"cta": "캘린더 열기"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13471,5 +13471,73 @@
|
||||
"clinicalUpper": "Klinische bovengrens",
|
||||
"clinicalLower": "Klinische ondergrens"
|
||||
}
|
||||
},
|
||||
"activityCalendar": {
|
||||
"title": "Activiteitenkalender",
|
||||
"periodLabel": "Kalenderperiode",
|
||||
"period": {
|
||||
"week": "Week",
|
||||
"month": "Maand"
|
||||
},
|
||||
"weekRange": "{start} t/m {end}",
|
||||
"previous": "Vorige periode",
|
||||
"next": "Volgende periode",
|
||||
"metricLabel": "Dagen kleuren op",
|
||||
"metric": {
|
||||
"steps": "Stappen",
|
||||
"activeMinutes": "Actieve minuten",
|
||||
"load": "Trainingsbelasting"
|
||||
},
|
||||
"empty": "Nog geen stappen, actieve minuten of trainingen in deze periode.",
|
||||
"loadError": "De activiteitenkalender kon niet worden geladen.",
|
||||
"weekGoalsColumn": "Weekdoelen",
|
||||
"weekGoalsMet": "Alle weekdoelen gehaald",
|
||||
"weekGoalsNotMet": "Niet alle weekdoelen gehaald",
|
||||
"cell": {
|
||||
"steps": "{value} stappen",
|
||||
"activeMinutes": "{value} actieve minuten",
|
||||
"workouts": "{value} trainingen: {sports}"
|
||||
},
|
||||
"level": {
|
||||
"none": "Geen gegevens",
|
||||
"unrated": "Nog te weinig geschiedenis",
|
||||
"low": "Ver onder je gewone niveau",
|
||||
"below": "Onder je gewone niveau",
|
||||
"usual": "Ongeveer zoals gewoonlijk",
|
||||
"above": "Boven je gewone niveau",
|
||||
"high": "Ver boven je gewone niveau"
|
||||
},
|
||||
"legend": "Vergeleken met je 12 weken daarvoor:",
|
||||
"noBaseline": "Je gebruikelijke bereik verschijnt na 14 dagen met gegevens.",
|
||||
"summary": {
|
||||
"steps": "{value} stappen",
|
||||
"activeMinutes": "{value} actieve minuten",
|
||||
"workouts": "{value} trainingen",
|
||||
"load": "Gemiddelde trainingsbelasting {value}",
|
||||
"usual": "Gebruikelijk tot dit punt: {value}",
|
||||
"noUsual": "Nog niet genoeg eerdere weken of maanden om mee te vergelijken."
|
||||
},
|
||||
"sportCount": "{sport}: {count}",
|
||||
"goal": {
|
||||
"workoutsPerWeek": "Trainingen per week",
|
||||
"activeMinutesPerWeek": "Actieve minuten per week",
|
||||
"stepsPerWeek": "Stappen per week",
|
||||
"stepsPerMonth": "Stappen per maand"
|
||||
},
|
||||
"goalOf": "{value} van {target}",
|
||||
"setGoals": "Doelen instellen",
|
||||
"editGoals": "Doelen wijzigen",
|
||||
"goalsSheet": {
|
||||
"title": "Activiteitsdoelen",
|
||||
"description": "Laat een veld leeg als je geen doel wilt.",
|
||||
"none": "Geen doel",
|
||||
"invalid": "Een heel getal van {min} tot {max}.",
|
||||
"saveError": "De doelen konden niet worden opgeslagen."
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Activiteitenkalender",
|
||||
"body": "Je stappen per week of maand, vergeleken met wat gewoon is voor jou.",
|
||||
"cta": "Kalender openen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13471,5 +13471,73 @@
|
||||
"clinicalUpper": "Kliniczna górna granica",
|
||||
"clinicalLower": "Kliniczna dolna granica"
|
||||
}
|
||||
},
|
||||
"activityCalendar": {
|
||||
"title": "Kalendarz aktywności",
|
||||
"periodLabel": "Zakres kalendarza",
|
||||
"period": {
|
||||
"week": "Tydzień",
|
||||
"month": "Miesiąc"
|
||||
},
|
||||
"weekRange": "od {start} do {end}",
|
||||
"previous": "Poprzedni okres",
|
||||
"next": "Następny okres",
|
||||
"metricLabel": "Koloruj dni według",
|
||||
"metric": {
|
||||
"steps": "Kroki",
|
||||
"activeMinutes": "Aktywne minuty",
|
||||
"load": "Obciążenie treningowe"
|
||||
},
|
||||
"empty": "W tym okresie nie ma jeszcze kroków, aktywnych minut ani treningów.",
|
||||
"loadError": "Nie udało się wczytać kalendarza aktywności.",
|
||||
"weekGoalsColumn": "Cele tygodniowe",
|
||||
"weekGoalsMet": "Wszystkie cele tygodniowe osiągnięte",
|
||||
"weekGoalsNotMet": "Nie wszystkie cele tygodniowe osiągnięte",
|
||||
"cell": {
|
||||
"steps": "Kroki: {value}",
|
||||
"activeMinutes": "Aktywne minuty: {value}",
|
||||
"workouts": "Treningi: {value} ({sports})"
|
||||
},
|
||||
"level": {
|
||||
"none": "Brak danych",
|
||||
"unrated": "Za mało historii",
|
||||
"low": "Znacznie poniżej twojej normy",
|
||||
"below": "Poniżej twojej normy",
|
||||
"usual": "Mniej więcej jak zwykle",
|
||||
"above": "Powyżej twojej normy",
|
||||
"high": "Znacznie powyżej twojej normy"
|
||||
},
|
||||
"legend": "W porównaniu z twoimi 12 wcześniejszymi tygodniami:",
|
||||
"noBaseline": "Twój zwykły zakres pojawi się po 14 dniach z danymi.",
|
||||
"summary": {
|
||||
"steps": "Kroki: {value}",
|
||||
"activeMinutes": "Aktywne minuty: {value}",
|
||||
"workouts": "Treningi: {value}",
|
||||
"load": "Średnie obciążenie treningowe {value}",
|
||||
"usual": "Twoja norma do tego momentu: {value}",
|
||||
"noUsual": "Za mało wcześniejszych tygodni lub miesięcy do porównania."
|
||||
},
|
||||
"sportCount": "{sport}: {count}",
|
||||
"goal": {
|
||||
"workoutsPerWeek": "Treningi w tygodniu",
|
||||
"activeMinutesPerWeek": "Aktywne minuty w tygodniu",
|
||||
"stepsPerWeek": "Kroki w tygodniu",
|
||||
"stepsPerMonth": "Kroki w miesiącu"
|
||||
},
|
||||
"goalOf": "{value} z {target}",
|
||||
"setGoals": "Ustaw cele",
|
||||
"editGoals": "Zmień cele",
|
||||
"goalsSheet": {
|
||||
"title": "Cele aktywności",
|
||||
"description": "Zostaw pole puste, jeśli nie chcesz celu.",
|
||||
"none": "Brak celu",
|
||||
"invalid": "Liczba całkowita od {min} do {max}.",
|
||||
"saveError": "Nie udało się zapisać celów."
|
||||
},
|
||||
"crossLink": {
|
||||
"title": "Kalendarz aktywności",
|
||||
"body": "Twoje kroki w tygodniu lub miesiącu na tle twojej normy.",
|
||||
"cta": "Otwórz kalendarz"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Optional per-user activity goals for the activity calendar: workouts,
|
||||
-- active minutes and steps per week, steps per month.
|
||||
--
|
||||
-- Nullable, no default, no backfill: null reads as "no goal set", which is
|
||||
-- every account's state today.
|
||||
--
|
||||
-- Reversibility: dropping the column removes the goals; nothing else reads it.
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN "activity_goals_json" JSONB;
|
||||
@@ -380,6 +380,12 @@ model User {
|
||||
/// Settings > System through `/api/settings/preview-features`.
|
||||
/// @pending-consumer(topics preview) the `topics` flag is stored and shown, but no surface reads it yet; the topic overview pages will.
|
||||
previewFeaturesJson Json? @map("preview_features_json")
|
||||
/// Optional activity goals the person set for themselves: workouts, active
|
||||
/// minutes and steps per week, steps per month. Null or an absent key = no
|
||||
/// goal. Parsed by `src/lib/validations/activity-goals.ts`, written through
|
||||
/// `/api/activity/goals`, read by the activity calendar
|
||||
/// (`/api/activity/calendar`), which reports progress against each goal set.
|
||||
activityGoalsJson Json? @map("activity_goals_json")
|
||||
// Blood-glucose display unit preference. Canonical storage is mg/dL.
|
||||
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
|
||||
// v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in.
|
||||
|
||||
@@ -1077,6 +1077,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "measurements",
|
||||
why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
|
||||
},
|
||||
"app/api/activity/calendar/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The activity calendar: day totals of steps, active minutes and load, workouts by sport, and the record owner's goals, computed without AI. Read-only, scoped to the resolved user, module-gated on the record's own `workouts` switch like the workout list beside it.",
|
||||
},
|
||||
"app/api/cycle/cycles/route.ts": {
|
||||
domain: "cycle",
|
||||
why: "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.",
|
||||
@@ -2172,7 +2176,7 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* v1.44 -- a guardian connects and disconnects a managed profile's OAuth
|
||||
* providers: two guardian-only entries. 266 -> 268.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 268;
|
||||
const FROZEN_ENTRY_COUNT = 269;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
@@ -82,6 +82,9 @@ const PINNED_AFFORDANCES: Record<
|
||||
// the two status grids used to spell the tint at every call site and now
|
||||
// name a tone instead, so the marker lives here once.
|
||||
"src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 },
|
||||
// v1.44: the activity calendar's per-week tick for "every weekly goal met",
|
||||
// a state of a row the person reads, not the outcome of a write.
|
||||
"src/components/activity/activity-calendar-card.tsx": { "text-success": 1 },
|
||||
// v1.42: the readiness inventory's "carries" status icon, one arm of the
|
||||
// carries / thin / empty tone table, beside its spoken status label.
|
||||
"src/components/timeline/readiness-inventory.tsx": { "text-success": 1 },
|
||||
|
||||
@@ -43,6 +43,9 @@ const IDENTITY_OWNER: Record<string, string> = {
|
||||
"lib/jobs/workout-insight-generate.ts",
|
||||
"lib/workouts/list-read.ts": "lib/workouts/list-read.ts",
|
||||
"lib/workouts/sport-context.ts": "lib/workouts/sport-context.ts",
|
||||
// The activity calendar counts workouts per day; a session two devices
|
||||
// recorded must count once there, as it does in the list.
|
||||
"lib/activity/calendar-read.ts": "lib/activity/calendar-read.ts",
|
||||
// The only indirection in the set. The Coach block is handed rows it does not
|
||||
// read itself; the snapshot builder fetches them in parallel with every other
|
||||
// block's data. Recording the owner here rather than assuming the call site
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* `/api/activity/calendar` and `/api/activity/goals` (D1036).
|
||||
*
|
||||
* The goals: a PATCH replaces only what it names, `null` clears, an unknown
|
||||
* key or a goal out of bounds is a 422 and writes nothing, and clearing the
|
||||
* last goal returns the column to NULL rather than storing an empty object.
|
||||
* The calendar: the module gate answers before any read, a future or malformed
|
||||
* day is refused, and the reader is asked for exactly the window the period
|
||||
* needs and never past today.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
readActivityDays: vi.fn(),
|
||||
requireModuleEnabled: vi.fn(),
|
||||
auditLog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (handler: unknown) => handler,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
|
||||
requireRecordAuth: vi.fn(async () => ({
|
||||
user: { id: "user-1" },
|
||||
actor: { id: "user-1" },
|
||||
grantId: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: mocks.findUnique, update: mocks.update } },
|
||||
toJson: (v: unknown) => v,
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: mocks.requireModuleEnabled,
|
||||
}));
|
||||
vi.mock("@/lib/activity/calendar-read", () => ({
|
||||
readActivityDays: mocks.readActivityDays,
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
|
||||
}));
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { GET as calendarGet } from "../calendar/route";
|
||||
import { GET as goalsGet, PATCH as goalsPatch } from "../goals/route";
|
||||
|
||||
type Handler = (req: NextRequest) => Promise<Response>;
|
||||
|
||||
function patch(body: unknown) {
|
||||
return (goalsPatch as unknown as Handler)(
|
||||
new NextRequest("http://localhost/api/activity/goals", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function calendar(query: string) {
|
||||
return (calendarGet as unknown as Handler)(
|
||||
new NextRequest(`http://localhost/api/activity/calendar${query}`),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findUnique.mockResolvedValue({
|
||||
activityGoalsJson: { workoutsPerWeek: 3, stepsPerWeek: 50000 },
|
||||
});
|
||||
mocks.update.mockResolvedValue({});
|
||||
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
|
||||
mocks.readActivityDays.mockResolvedValue({ days: [], loadType: null });
|
||||
});
|
||||
|
||||
describe("activity goals", () => {
|
||||
it("reads every goal, unset ones as null", async () => {
|
||||
const res = await (goalsGet as unknown as () => Promise<Response>)();
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({
|
||||
workoutsPerWeek: 3,
|
||||
activeMinutesPerWeek: null,
|
||||
stepsPerWeek: 50000,
|
||||
stepsPerMonth: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("changes what the body names and keeps the rest", async () => {
|
||||
const res = await patch({ stepsPerWeek: null, stepsPerMonth: 250000 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(mocks.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
activityGoalsJson: {
|
||||
workoutsPerWeek: 3,
|
||||
activeMinutesPerWeek: null,
|
||||
stepsPerWeek: null,
|
||||
stepsPerMonth: 250000,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mocks.auditLog).toHaveBeenCalledWith(
|
||||
"activity.goals.update",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stores NULL once the last goal is cleared", async () => {
|
||||
await patch({ workoutsPerWeek: null, stepsPerWeek: null });
|
||||
expect(mocks.update.mock.calls[0][0].data.activityGoalsJson).toBe(
|
||||
Prisma.DbNull,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ caloriesPerWeek: 3000 }],
|
||||
[{ workoutsPerWeek: 0 }],
|
||||
[{ stepsPerWeek: 2.5 }],
|
||||
[{ stepsPerMonth: "200000" }],
|
||||
])("refuses %j without writing", async (body) => {
|
||||
const res = await patch(body);
|
||||
expect(res.status).toBe(422);
|
||||
expect(mocks.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("activity calendar", () => {
|
||||
it("answers the module gate before reading anything", async () => {
|
||||
mocks.requireModuleEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
response: new Response(null, { status: 403 }),
|
||||
});
|
||||
const res = await calendar("?period=month");
|
||||
expect(res.status).toBe(403);
|
||||
expect(mocks.readActivityDays).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["?period=year", "?date=2026-13-01", "?date=yesterday"])(
|
||||
"refuses %s",
|
||||
async (q) => {
|
||||
const res = await calendar(q);
|
||||
expect(res.status).toBe(422);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a day after today", async () => {
|
||||
const res = await calendar("?date=2999-01-01");
|
||||
expect(res.status).toBe(422);
|
||||
expect(mocks.readActivityDays).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the baseline window before the period and nothing past the grid", async () => {
|
||||
const res = await calendar("?period=week&date=2025-03-12");
|
||||
expect(res.status).toBe(200);
|
||||
const args = mocks.readActivityDays.mock.calls[0][0];
|
||||
// Week of Monday 2025-03-10; 84 days before it.
|
||||
expect(args.fromKey).toBe("2024-12-16");
|
||||
expect(args.toKey).toBe("2025-03-16");
|
||||
expect(args.tz).toBe("Europe/Berlin");
|
||||
const body = await res.json();
|
||||
expect(body.data.start).toBe("2025-03-10");
|
||||
expect(body.data.goals.workoutsPerWeek).toBe(3);
|
||||
expect(body.data.days).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* `GET /api/activity/calendar` — a week or a month of activity against the
|
||||
* person's own normal, with their weekly and monthly goals (D1036).
|
||||
*
|
||||
* Computed server-side, without AI, from the stored readings: steps (the
|
||||
* canonical-source day totals the steps chart draws), exercise minutes,
|
||||
* workouts after the cross-source collapse, and a training-load score when a
|
||||
* device or HealthLog provides one. What "usual" means is in
|
||||
* `src/lib/activity/calendar-math.ts`; it is always the person's own history,
|
||||
* never a population norm.
|
||||
*
|
||||
* Owned by the Workouts module, like the page that shows it: a switched-off
|
||||
* module answers 403 here as `/api/workouts` does.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
buildActivityCalendar,
|
||||
periodBounds,
|
||||
} from "@/lib/activity/calendar-math";
|
||||
import { readActivityDays } from "@/lib/activity/calendar-read";
|
||||
import { daysBetweenDateKeys, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { activityCalendarQuerySchema } from "@/lib/validations/activity-calendar";
|
||||
import { parseActivityGoals } from "@/lib/validations/activity-goals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** How far back the calendar pages: ten years, the series route's ceiling. */
|
||||
const MAX_DAYS_BACK = 3650 - 200;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
annotate({ action: { name: "activity.calendar.read" } });
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "workouts");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = activityCalendarQuerySchema.safeParse({
|
||||
period: params.get("period") ?? undefined,
|
||||
date: params.get("date") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const tz = await resolveUserTimezone(user.id);
|
||||
const now = new Date();
|
||||
const today = userDayKey(now, tz);
|
||||
const anchor = parsed.data.date ?? today;
|
||||
if (anchor > today) {
|
||||
return apiError("The calendar cannot show a day after today", 422, {
|
||||
errorCode: "activity.calendar.future_date",
|
||||
});
|
||||
}
|
||||
if (daysBetweenDateKeys(anchor, today) > MAX_DAYS_BACK) {
|
||||
return apiError("The calendar reaches back ten years", 422, {
|
||||
errorCode: "activity.calendar.too_old",
|
||||
});
|
||||
}
|
||||
|
||||
const bounds = periodBounds(parsed.data.period, anchor);
|
||||
const lastRead = bounds.gridEnd < today ? bounds.gridEnd : today;
|
||||
const [{ days, loadType }, goalRow] = await Promise.all([
|
||||
readActivityDays({
|
||||
userId: user.id,
|
||||
tz,
|
||||
fromKey: bounds.readStart,
|
||||
toKey: lastRead,
|
||||
now,
|
||||
includeWorkouts: true,
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { activityGoalsJson: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const calendar = buildActivityCalendar({
|
||||
bounds,
|
||||
today,
|
||||
input: days,
|
||||
goals: parseActivityGoals(goalRow?.activityGoalsJson ?? null),
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
activity_calendar_period: bounds.period,
|
||||
activity_calendar_days_read: days.length,
|
||||
activity_calendar_load_type: loadType ?? "none",
|
||||
},
|
||||
});
|
||||
return apiSuccess({ ...calendar, timezone: tz, loadType });
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* GET / PATCH the caller's activity goals (D1036).
|
||||
*
|
||||
* Every goal is optional and set by the person; there is no default goal.
|
||||
* PATCH replaces the goals the body names (`null` clears one) and keeps the
|
||||
* rest; an unknown key or an out-of-bounds goal is refused with the
|
||||
* multi-issue 422. The goals live on their own `User` column
|
||||
* (`activity_goals_json`) and the activity calendar reads them.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
ACTIVITY_GOAL_KEYS,
|
||||
activityGoalsSchema,
|
||||
hasNoActivityGoals,
|
||||
mergeActivityGoals,
|
||||
parseActivityGoals,
|
||||
type ActivityGoals,
|
||||
} from "@/lib/validations/activity-goals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function readGoals(userId: string): Promise<ActivityGoals> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { activityGoalsJson: true },
|
||||
});
|
||||
return parseActivityGoals(row?.activityGoalsJson ?? null);
|
||||
}
|
||||
|
||||
function setKeys(goals: ActivityGoals): string[] {
|
||||
return ACTIVITY_GOAL_KEYS.filter((key) => goals[key] !== null);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
// Owner-only, like the write: a delegate sees the goals where they matter,
|
||||
// inside the calendar, which carries them.
|
||||
const { user } = await requireAuth();
|
||||
const goals = await readGoals(user.id);
|
||||
annotate({
|
||||
action: { name: "activity.goals.read" },
|
||||
meta: { set: setKeys(goals) },
|
||||
});
|
||||
return apiSuccess(goals);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = activityGoalsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({ action: { name: "activity.goals.validation-failed" } });
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const next = mergeActivityGoals(await readGoals(user.id), parsed.data);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
activityGoalsJson: hasNoActivityGoals(next)
|
||||
? Prisma.DbNull
|
||||
: toJson(next),
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("activity.goals.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { set: setKeys(next) },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "activity.goals.update" },
|
||||
meta: { set: setKeys(next) },
|
||||
});
|
||||
return apiSuccess(next);
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Footprints } from "lucide-react";
|
||||
import { CalendarDays, Footprints } from "lucide-react";
|
||||
|
||||
import { CrossLinkCard } from "@/components/insights/cross-link-card";
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.12 — `/insights/steps`.
|
||||
@@ -15,8 +18,14 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
|
||||
* user having step data. Reuses the generic HealthKitMetricPage scaffold;
|
||||
* empty-state carries no manual-entry CTA — the series arrives from Apple
|
||||
* Health or Withings sync.
|
||||
*
|
||||
* v1.44 — links across to the activity calendar on the workouts page, which
|
||||
* shows the same day totals as a week or month against the person's usual.
|
||||
* The link follows the Workouts module: switched off, it is not offered.
|
||||
*/
|
||||
export default function InsightsStepsPage() {
|
||||
const { t } = useTranslations();
|
||||
const calendarVisible = useSurfaceVisible("insights-page:workouts");
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="ACTIVITY_STEPS"
|
||||
@@ -32,6 +41,18 @@ export default function InsightsStepsPage() {
|
||||
emptyStateCtaType={null}
|
||||
captureType="ACTIVITY_STEPS"
|
||||
coachPrefill="I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?"
|
||||
afterChart={
|
||||
calendarVisible ? (
|
||||
<CrossLinkCard
|
||||
href="/insights/workouts#activity-calendar"
|
||||
slot="steps-activity-calendar-link"
|
||||
icon={CalendarDays}
|
||||
title={t("activityCalendar.crossLink.title")}
|
||||
body={t("activityCalendar.crossLink.body")}
|
||||
cta={t("activityCalendar.crossLink.cta")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { canLogWorkout } from "@/lib/workouts/manual-entry";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ActivityCalendarCard } from "@/components/activity/activity-calendar-card";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/workouts`.
|
||||
@@ -107,6 +108,11 @@ export default function InsightsWorkoutsPageClient() {
|
||||
{showLog ? (
|
||||
<ManualWorkoutSheet open={logOpen} onOpenChange={setLogOpen} />
|
||||
) : null}
|
||||
{/* The activity calendar (D1036): steps, active minutes and workouts
|
||||
against the person's own usual. It stands above the list and draws
|
||||
from steps alone too, so an account without workouts still sees
|
||||
its routine. */}
|
||||
<ActivityCalendarCard />
|
||||
{/* No `<MetricRangeControls>` here: workouts are session records, not a
|
||||
MeasurementType series, so the period-over-period range read has
|
||||
nothing to aggregate. */}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* `<ActivityCalendarCard>` draws what the server computed (SSR markup, like
|
||||
* the other component suites). The arithmetic is pinned in
|
||||
* `src/lib/activity/__tests__/calendar-math.test.ts`; this suite pins that
|
||||
* the card shows each day's level, the workouts, the summary against the
|
||||
* usual and the goals, and that a shared record gets no goal editor.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
buildActivityCalendar,
|
||||
periodBounds,
|
||||
type ActivityDayInput,
|
||||
} from "@/lib/activity/calendar-math";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { NO_ACTIVITY_GOALS } from "@/lib/validations/activity-goals";
|
||||
|
||||
const state: { data: unknown; shared: boolean } = {
|
||||
data: undefined,
|
||||
shared: false,
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-activity-calendar", () => ({
|
||||
useActivityCalendar: () => ({
|
||||
data: state.data,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useSaveActivityGoals: () => ({
|
||||
mutate: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ inSharedRecord: state.shared }),
|
||||
}));
|
||||
|
||||
import { ActivityCalendarCard } from "../activity-calendar-card";
|
||||
import { parseGoalField } from "../activity-goals-sheet";
|
||||
|
||||
function calendar() {
|
||||
const today = "2026-10-21";
|
||||
const bounds = periodBounds("month", today);
|
||||
const input: ActivityDayInput[] = [];
|
||||
for (let i = 1; i <= 200; i++) {
|
||||
input.push({
|
||||
date: shiftDateKey(bounds.start, -i),
|
||||
steps: 6000 + (i % 4) * 500,
|
||||
exerciseMinutes: 30,
|
||||
workoutMinutes: 0,
|
||||
workoutCount: 0,
|
||||
workoutsBySport: {},
|
||||
load: null,
|
||||
});
|
||||
}
|
||||
input.push({
|
||||
date: "2026-10-05",
|
||||
steps: 20000,
|
||||
exerciseMinutes: 90,
|
||||
workoutMinutes: 75,
|
||||
workoutCount: 1,
|
||||
workoutsBySport: { running: 1 },
|
||||
load: null,
|
||||
});
|
||||
return {
|
||||
...buildActivityCalendar({
|
||||
bounds,
|
||||
today,
|
||||
input,
|
||||
goals: {
|
||||
...NO_ACTIVITY_GOALS,
|
||||
workoutsPerWeek: 1,
|
||||
stepsPerMonth: 100000,
|
||||
},
|
||||
}),
|
||||
timezone: "Europe/Berlin",
|
||||
loadType: null,
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ActivityCalendarCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ActivityCalendarCard", () => {
|
||||
it("shows a loading frame before the data arrives", () => {
|
||||
state.data = undefined;
|
||||
expect(render()).toContain('data-slot="activity-calendar-loading"');
|
||||
});
|
||||
|
||||
it("draws every day of the grid with its level", () => {
|
||||
state.data = calendar();
|
||||
const html = render();
|
||||
const days = html.match(/data-slot="activity-calendar-day"/g) ?? [];
|
||||
expect(days.length).toBe(35);
|
||||
expect(html).toMatch(/data-date="2026-10-05" data-level="high"/);
|
||||
expect(html).toMatch(/data-date="2026-10-30" data-level="none"/);
|
||||
// The accessible name carries the figures and the level in words.
|
||||
expect(html).toContain("20,000 steps");
|
||||
expect(html).toContain("Far above your usual");
|
||||
});
|
||||
|
||||
it("summarises the period against the usual and lists the sports", () => {
|
||||
state.data = calendar();
|
||||
const html = render();
|
||||
expect(html).toContain('data-metric="steps"');
|
||||
expect(html).toContain("Your usual by this point:");
|
||||
expect(html).toContain("Running: 1");
|
||||
});
|
||||
|
||||
it("shows goal progress and a week tick column", () => {
|
||||
state.data = calendar();
|
||||
const html = render();
|
||||
expect(html).toContain("Steps per month");
|
||||
expect(html).toContain(
|
||||
'data-slot="activity-calendar-week-goal" data-met="true"',
|
||||
);
|
||||
expect(html).toContain('data-slot="activity-calendar-set-goals"');
|
||||
});
|
||||
|
||||
it("offers no goal editor inside somebody else's record", () => {
|
||||
state.data = calendar();
|
||||
state.shared = true;
|
||||
const html = render();
|
||||
state.shared = false;
|
||||
expect(html).not.toContain('data-slot="activity-calendar-set-goals"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("goal field parsing", () => {
|
||||
it("blank clears, digits within bounds pass, anything else is refused", () => {
|
||||
expect(parseGoalField("workoutsPerWeek", " ")).toEqual({
|
||||
ok: true,
|
||||
value: null,
|
||||
});
|
||||
expect(parseGoalField("workoutsPerWeek", "4")).toEqual({
|
||||
ok: true,
|
||||
value: 4,
|
||||
});
|
||||
expect(parseGoalField("workoutsPerWeek", "0")).toEqual({ ok: false });
|
||||
expect(parseGoalField("stepsPerWeek", "10.000")).toEqual({ ok: false });
|
||||
expect(parseGoalField("stepsPerWeek", "70000")).toEqual({
|
||||
ok: true,
|
||||
value: 70000,
|
||||
});
|
||||
expect(parseGoalField("stepsPerMonth", "99999999")).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ScorePill } from "@/components/ui/score-pill";
|
||||
import { Segmented } from "@/components/ui/segmented";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { ActivityGoalsSheet } from "@/components/activity/activity-goals-sheet";
|
||||
import { useActivityCalendar } from "@/hooks/use-activity-calendar";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ActivityCalendarResponse } from "@/lib/validations/activity-calendar";
|
||||
|
||||
type Period = "week" | "month";
|
||||
type RatedMetric = "steps" | "activeMinutes" | "load";
|
||||
type CalendarDay = ActivityCalendarResponse["days"][number];
|
||||
type DayLevel = CalendarDay["level"]["steps"];
|
||||
|
||||
/**
|
||||
* One hue, rising intensity: the calendar shows more and less than usual,
|
||||
* not good and bad, so no status colour takes part. The bar is a graphic
|
||||
* (3:1 floor), the day number beside it stays foreground text.
|
||||
*/
|
||||
const LEVEL_BAR: Record<DayLevel, string> = {
|
||||
none: "bg-transparent",
|
||||
unrated: "bg-muted-foreground/60",
|
||||
low: "bg-chart-2/20",
|
||||
below: "bg-chart-2/40",
|
||||
usual: "bg-chart-2/60",
|
||||
above: "bg-chart-2/80",
|
||||
high: "bg-chart-2",
|
||||
};
|
||||
|
||||
const LEGEND_LEVELS: DayLevel[] = ["low", "below", "usual", "above", "high"];
|
||||
|
||||
/** 2024-01-01 is a Monday: seven UTC days from it name the week's columns. */
|
||||
function weekdayLabels(locale: string): string[] {
|
||||
const fmt = new Intl.DateTimeFormat(locale, {
|
||||
weekday: "narrow",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
fmt.format(new Date(Date.UTC(2024, 0, 1 + i))),
|
||||
);
|
||||
}
|
||||
|
||||
function dateOfKey(key: string): Date {
|
||||
return new Date(`${key}T12:00:00.000Z`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The activity calendar (D1036): a week or a month of steps, active minutes,
|
||||
* workouts and training load against the person's own usual, with the goals
|
||||
* they set. Every figure comes from `/api/activity/calendar`; this component
|
||||
* draws, it does not compute.
|
||||
*/
|
||||
export function ActivityCalendarCard() {
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const capabilities = useRecordCapabilities();
|
||||
const [period, setPeriod] = useState<Period>("month");
|
||||
const [anchor, setAnchor] = useState<string | null>(null);
|
||||
const [metricChoice, setMetricChoice] = useState<RatedMetric | null>(null);
|
||||
const [goalsOpen, setGoalsOpen] = useState(false);
|
||||
// Each opening mounts a fresh sheet, so its draft starts from the stored
|
||||
// goals; keyed on the count rather than on `open`, so closing keeps the
|
||||
// sheet mounted for its exit animation.
|
||||
const [goalsOpenCount, setGoalsOpenCount] = useState(0);
|
||||
const query = useActivityCalendar(period, anchor);
|
||||
const data = query.data;
|
||||
const weekdays = useMemo(() => weekdayLabels(locale), [locale]);
|
||||
|
||||
const ratedOptions = useMemo(() => {
|
||||
if (!data) return [] as RatedMetric[];
|
||||
return (["steps", "activeMinutes", "load"] as const).filter(
|
||||
(m) => data.available[m],
|
||||
);
|
||||
}, [data]);
|
||||
const metric: RatedMetric | null =
|
||||
metricChoice && ratedOptions.includes(metricChoice)
|
||||
? metricChoice
|
||||
: (ratedOptions[0] ?? null);
|
||||
|
||||
const periodLabel = data
|
||||
? period === "month"
|
||||
? new Intl.DateTimeFormat(locale, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(dateOfKey(data.start))
|
||||
: t("activityCalendar.weekRange", {
|
||||
start: fmt.dateShort(dateOfKey(data.start)),
|
||||
end: fmt.dateShort(dateOfKey(data.end)),
|
||||
})
|
||||
: "";
|
||||
|
||||
const formatMetric = (m: RatedMetric | "workouts", value: number) =>
|
||||
m === "load" ? fmt.number(value, 1) : fmt.integer(Math.round(value));
|
||||
|
||||
const sportLabel = (sport: string) =>
|
||||
translateOrNull(t, `insights.workouts.sport.${sport}`) ?? sport;
|
||||
|
||||
const weeklyGoalsSet =
|
||||
data !== undefined &&
|
||||
(data.goals.workoutsPerWeek !== null ||
|
||||
data.goals.activeMinutesPerWeek !== null ||
|
||||
data.goals.stepsPerWeek !== null);
|
||||
|
||||
const levelText = (level: DayLevel) => t(`activityCalendar.level.${level}`);
|
||||
|
||||
const dayLabel = (day: CalendarDay) => {
|
||||
const parts = [fmt.dateWithWeekday(dateOfKey(day.date))];
|
||||
if (day.future) return parts.join(", ");
|
||||
if (day.steps !== null)
|
||||
parts.push(
|
||||
t("activityCalendar.cell.steps", { value: fmt.integer(day.steps) }),
|
||||
);
|
||||
if (day.activeMinutes !== null)
|
||||
parts.push(
|
||||
t("activityCalendar.cell.activeMinutes", {
|
||||
value: fmt.integer(day.activeMinutes),
|
||||
}),
|
||||
);
|
||||
if (day.workoutCount > 0)
|
||||
parts.push(
|
||||
t("activityCalendar.cell.workouts", {
|
||||
value: fmt.integer(day.workoutCount),
|
||||
sports: Object.keys(day.workoutsBySport).map(sportLabel).join(", "),
|
||||
}),
|
||||
);
|
||||
if (metric) parts.push(levelText(day.level[metric]));
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
const rows: CalendarDay[][] = [];
|
||||
if (data) {
|
||||
for (let i = 0; i < data.days.length; i += 7) {
|
||||
rows.push(data.days.slice(i, i + 7));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
id="activity-calendar"
|
||||
data-slot="activity-calendar"
|
||||
className="scroll-mt-28"
|
||||
>
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={CalendarDays}
|
||||
title={t("activityCalendar.title")}
|
||||
right={
|
||||
<Segmented
|
||||
size="sm"
|
||||
label={t("activityCalendar.periodLabel")}
|
||||
slot="activity-calendar-period"
|
||||
value={period}
|
||||
onChange={(next) => {
|
||||
setPeriod(next);
|
||||
setAnchor(data ? data.start : null);
|
||||
}}
|
||||
options={[
|
||||
{ value: "week", label: t("activityCalendar.period.week") },
|
||||
{ value: "month", label: t("activityCalendar.period.month") },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{query.isError ? (
|
||||
<QueryErrorRow
|
||||
inset
|
||||
error={query.error}
|
||||
message={t("activityCalendar.loadError")}
|
||||
onRetry={() => query.refetch()}
|
||||
/>
|
||||
) : !data ? (
|
||||
<div data-slot="activity-calendar-loading" className="space-y-2">
|
||||
<Skeleton className="h-8 w-full rounded-lg" />
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="pointer-coarse:size-11"
|
||||
aria-label={t("activityCalendar.previous")}
|
||||
data-slot="activity-calendar-previous"
|
||||
onClick={() => setAnchor(data.previous)}
|
||||
>
|
||||
<ChevronLeft className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<p
|
||||
className="text-sm font-medium"
|
||||
data-slot="activity-calendar-label"
|
||||
aria-live="polite"
|
||||
>
|
||||
{periodLabel}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="pointer-coarse:size-11"
|
||||
aria-label={t("activityCalendar.next")}
|
||||
data-slot="activity-calendar-next"
|
||||
disabled={data.next === null}
|
||||
onClick={() => data.next && setAnchor(data.next)}
|
||||
>
|
||||
<ChevronRight className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ratedOptions.length > 1 && metric ? (
|
||||
<Segmented
|
||||
size="sm"
|
||||
stretch
|
||||
label={t("activityCalendar.metricLabel")}
|
||||
slot="activity-calendar-metric"
|
||||
value={metric}
|
||||
onChange={setMetricChoice}
|
||||
options={ratedOptions.map((m) => ({
|
||||
value: m,
|
||||
label: t(`activityCalendar.metric.${m}`),
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!data.available.steps &&
|
||||
!data.available.activeMinutes &&
|
||||
!data.available.workouts &&
|
||||
!data.available.load ? (
|
||||
<EmptyState
|
||||
variant="plain"
|
||||
size="compact"
|
||||
title={t("activityCalendar.empty")}
|
||||
data-slot="activity-calendar-empty"
|
||||
/>
|
||||
) : (
|
||||
<div role="table" aria-label={periodLabel} className="space-y-1">
|
||||
<div role="row" className="flex gap-1">
|
||||
{weekdays.map((label, i) => (
|
||||
<span
|
||||
key={i}
|
||||
role="columnheader"
|
||||
className="text-muted-foreground flex-1 text-center text-xs"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
{weeklyGoalsSet && period === "month" ? (
|
||||
<span role="columnheader" className="w-5 shrink-0">
|
||||
<span className="sr-only">
|
||||
{t("activityCalendar.weekGoalsColumn")}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{rows.map((week, rowIndex) => {
|
||||
const weekRow = data.weeks[rowIndex];
|
||||
const allMet =
|
||||
weekRow && weekRow.goals.length > 0
|
||||
? weekRow.goals.every((g) => g.met)
|
||||
: null;
|
||||
return (
|
||||
<div role="row" key={week[0].date} className="flex gap-1">
|
||||
{week.map((day) => {
|
||||
const level = metric ? day.level[metric] : "none";
|
||||
const value =
|
||||
metric && !day.future ? day[metric] : null;
|
||||
return (
|
||||
<div
|
||||
role="cell"
|
||||
key={day.date}
|
||||
data-slot="activity-calendar-day"
|
||||
data-date={day.date}
|
||||
data-level={level}
|
||||
aria-label={dayLabel(day)}
|
||||
className={cn(
|
||||
"flex min-h-11 flex-1 flex-col items-center gap-1 rounded-lg border px-0.5 py-1",
|
||||
!day.inPeriod && "opacity-50",
|
||||
day.date === data.today && "border-primary",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"text-xs tabular-nums",
|
||||
day.future
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{Number(day.date.slice(8))}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-1.5 w-full max-w-8 rounded-full",
|
||||
LEVEL_BAR[level],
|
||||
)}
|
||||
/>
|
||||
{period === "week" && value !== null ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-2xs text-foreground tabular-nums"
|
||||
>
|
||||
{formatMetric(metric as RatedMetric, value)}
|
||||
</span>
|
||||
) : null}
|
||||
{day.workoutCount > 0 ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex items-center gap-0.5"
|
||||
>
|
||||
{Array.from({
|
||||
length: Math.min(3, day.workoutCount),
|
||||
}).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg-chart-1 size-1.5 rounded-full"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{weeklyGoalsSet && period === "month" ? (
|
||||
<span
|
||||
role="cell"
|
||||
className="flex w-5 shrink-0 items-center justify-center"
|
||||
data-slot="activity-calendar-week-goal"
|
||||
data-met={allMet === null ? "none" : String(allMet)}
|
||||
>
|
||||
{allMet ? (
|
||||
<Check
|
||||
className="text-success size-4"
|
||||
aria-label={t("activityCalendar.weekGoalsMet")}
|
||||
/>
|
||||
) : allMet === false ? (
|
||||
<span className="sr-only">
|
||||
{t("activityCalendar.weekGoalsNotMet")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metric ? (
|
||||
<div
|
||||
className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs"
|
||||
data-slot="activity-calendar-legend"
|
||||
>
|
||||
<span>{t("activityCalendar.legend")}</span>
|
||||
{LEGEND_LEVELS.map((level) => (
|
||||
<span key={level} className="inline-flex items-center gap-1">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("h-1.5 w-4 rounded-full", LEVEL_BAR[level])}
|
||||
/>
|
||||
{levelText(level)}
|
||||
</span>
|
||||
))}
|
||||
{data.baseline[metric] === null ? (
|
||||
<span data-slot="activity-calendar-no-baseline">
|
||||
{t("activityCalendar.noBaseline")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ul className="space-y-2" data-slot="activity-calendar-summary">
|
||||
{(["steps", "activeMinutes", "workouts", "load"] as const)
|
||||
.filter((m) => data.available[m])
|
||||
.map((m) => {
|
||||
const s = data.summary[m];
|
||||
const delta =
|
||||
s.value !== null && s.usualToDate
|
||||
? (s.value - s.usualToDate) / s.usualToDate
|
||||
: null;
|
||||
return (
|
||||
<li
|
||||
key={m}
|
||||
data-slot="activity-calendar-summary-row"
|
||||
data-metric={m}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm">
|
||||
{t(`activityCalendar.summary.${m}`, {
|
||||
value:
|
||||
s.value === null ? "–" : formatMetric(m, s.value),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{s.usualToDate === null
|
||||
? t("activityCalendar.summary.noUsual")
|
||||
: t("activityCalendar.summary.usual", {
|
||||
value: formatMetric(m, s.usualToDate),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{delta !== null ? (
|
||||
<ScorePill tone="neutral" numeric className="shrink-0">
|
||||
{`${delta > 0 ? "+" : ""}${fmt.percent(delta)}`}
|
||||
</ScorePill>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{data.sports.length > 0 ? (
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
data-slot="activity-calendar-sports"
|
||||
>
|
||||
{data.sports.map((s) => (
|
||||
<TagChip key={s.sportType}>
|
||||
{t("activityCalendar.sportCount", {
|
||||
sport: sportLabel(s.sportType),
|
||||
count: fmt.integer(s.count),
|
||||
})}
|
||||
</TagChip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2" data-slot="activity-calendar-goals">
|
||||
{data.goalProgress.map((g) => (
|
||||
<div key={g.key} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Target
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(`activityCalendar.goal.${g.key}`)}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{t("activityCalendar.goalOf", {
|
||||
value: fmt.integer(Math.round(g.value)),
|
||||
target: fmt.integer(g.target),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(100, (g.value / g.target) * 100)}
|
||||
aria-label={t(`activityCalendar.goal.${g.key}`)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!capabilities.inSharedRecord ? (
|
||||
<InlineAction
|
||||
type="button"
|
||||
tone="primary"
|
||||
size="xs"
|
||||
data-slot="activity-calendar-set-goals"
|
||||
onClick={() => {
|
||||
setGoalsOpenCount((n) => n + 1);
|
||||
setGoalsOpen(true);
|
||||
}}
|
||||
>
|
||||
{data.goalProgress.length > 0 || weeklyGoalsSet
|
||||
? t("activityCalendar.editGoals")
|
||||
: t("activityCalendar.setGoals")}
|
||||
</InlineAction>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
{data && !capabilities.inSharedRecord ? (
|
||||
<ActivityGoalsSheet
|
||||
key={goalsOpenCount}
|
||||
open={goalsOpen}
|
||||
onOpenChange={setGoalsOpen}
|
||||
goals={data.goals}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useSaveActivityGoals } from "@/hooks/use-activity-calendar";
|
||||
import {
|
||||
ACTIVITY_GOAL_BOUNDS,
|
||||
ACTIVITY_GOAL_KEYS,
|
||||
type ActivityGoalKey,
|
||||
} from "@/lib/activity/goal-bounds";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
type Goals = Record<ActivityGoalKey, number | null>;
|
||||
type Draft = Record<ActivityGoalKey, string>;
|
||||
|
||||
function toDraft(goals: Goals): Draft {
|
||||
return Object.fromEntries(
|
||||
ACTIVITY_GOAL_KEYS.map((k) => [
|
||||
k,
|
||||
goals[k] === null ? "" : String(goals[k]),
|
||||
]),
|
||||
) as Draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one field. Blank clears the goal; anything else must be a whole
|
||||
* number inside the bounds the server enforces. Digits only, so a grouping
|
||||
* separator typed by habit ("10.000") is refused rather than misread.
|
||||
*/
|
||||
export function parseGoalField(
|
||||
key: ActivityGoalKey,
|
||||
raw: string,
|
||||
): { ok: true; value: number | null } | { ok: false } {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === "") return { ok: true, value: null };
|
||||
if (!/^\d+$/.test(trimmed)) return { ok: false };
|
||||
const value = Number(trimmed);
|
||||
const { min, max } = ACTIVITY_GOAL_BOUNDS[key];
|
||||
if (value < min || value > max) return { ok: false };
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear the activity goals. Every goal is optional; an empty field is
|
||||
* no goal. There is no suggested number: the goal is the person's own.
|
||||
*
|
||||
* The draft starts from `goals` when the sheet mounts; the caller remounts it
|
||||
* on every open (a `key`), so a reopened sheet never shows a stale draft.
|
||||
*/
|
||||
export function ActivityGoalsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
goals,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
goals: Goals;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const save = useSaveActivityGoals();
|
||||
const [draft, setDraft] = useState<Draft>(() => toDraft(goals));
|
||||
const [invalid, setInvalid] = useState<ActivityGoalKey[]>([]);
|
||||
|
||||
const submit = () => {
|
||||
const bad: ActivityGoalKey[] = [];
|
||||
const patch: Partial<Goals> = {};
|
||||
for (const key of ACTIVITY_GOAL_KEYS) {
|
||||
const parsed = parseGoalField(key, draft[key]);
|
||||
if (!parsed.ok) bad.push(key);
|
||||
else patch[key] = parsed.value;
|
||||
}
|
||||
setInvalid(bad);
|
||||
if (bad.length > 0) return;
|
||||
save.mutate(patch, { onSuccess: () => onOpenChange(false) });
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("activityCalendar.goalsSheet.title")}
|
||||
description={t("activityCalendar.goalsSheet.description")}
|
||||
footer={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot="activity-goals-save"
|
||||
disabled={save.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
data-slot="activity-goals-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
{ACTIVITY_GOAL_KEYS.map((key) => {
|
||||
const id = `activity-goal-${key}`;
|
||||
const { min, max } = ACTIVITY_GOAL_BOUNDS[key];
|
||||
const isInvalid = invalid.includes(key);
|
||||
return (
|
||||
<div key={key} className="grid gap-1.5">
|
||||
<Label htmlFor={id}>{t(`activityCalendar.goal.${key}`)}</Label>
|
||||
<Input
|
||||
id={id}
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={draft[key]}
|
||||
aria-invalid={isInvalid || undefined}
|
||||
aria-describedby={isInvalid ? `${id}-error` : undefined}
|
||||
placeholder={t("activityCalendar.goalsSheet.none")}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, [key]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
{isInvalid ? (
|
||||
<p
|
||||
id={`${id}-error`}
|
||||
role="alert"
|
||||
className="text-destructive text-xs"
|
||||
>
|
||||
{t("activityCalendar.goalsSheet.invalid", {
|
||||
min: fmt.integer(min),
|
||||
max: fmt.integer(max),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{save.isError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("activityCalendar.goalsSheet.saveError")}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ActivityCalendarResponse } from "@/lib/validations/activity-calendar";
|
||||
import type {
|
||||
ActivityGoals,
|
||||
ActivityGoalsInput,
|
||||
} from "@/lib/validations/activity-goals";
|
||||
|
||||
/**
|
||||
* `GET /api/activity/calendar` for one period. `date` null = the period that
|
||||
* contains today. The server computes everything, the client only draws it.
|
||||
*/
|
||||
export function useActivityCalendar(
|
||||
period: "week" | "month",
|
||||
date: string | null,
|
||||
opts?: { enabled?: boolean },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.activityCalendar(period, date),
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ period });
|
||||
if (date) params.set("date", date);
|
||||
return apiGet<ActivityCalendarResponse>(
|
||||
`/api/activity/calendar?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
enabled: opts?.enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** `PATCH /api/activity/goals`; refreshes every calendar cell on success. */
|
||||
export function useSaveActivityGoals() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (patch: ActivityGoalsInput) =>
|
||||
apiPatch<ActivityGoals>("/api/activity/goals", patch),
|
||||
onSuccess: (goals) => {
|
||||
queryClient.setQueryData(queryKeys.activityGoals(), goals);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.activityCalendarAll(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { NO_ACTIVITY_GOALS } from "@/lib/validations/activity-goals";
|
||||
|
||||
import {
|
||||
BASELINE_DAYS,
|
||||
MIN_BASELINE_DAYS,
|
||||
activeMinutesOf,
|
||||
baselineStats,
|
||||
buildActivityCalendar,
|
||||
comparePeriod,
|
||||
dayLevel,
|
||||
daysInMonth,
|
||||
goalProgress,
|
||||
periodBounds,
|
||||
quantileSorted,
|
||||
shiftMonth,
|
||||
weekStartOf,
|
||||
type ActivityDayInput,
|
||||
} from "../calendar-math";
|
||||
|
||||
function day(
|
||||
date: string,
|
||||
over: Partial<ActivityDayInput> = {},
|
||||
): ActivityDayInput {
|
||||
return {
|
||||
date,
|
||||
steps: null,
|
||||
exerciseMinutes: null,
|
||||
workoutMinutes: 0,
|
||||
workoutCount: 0,
|
||||
workoutsBySport: {},
|
||||
load: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("quantiles", () => {
|
||||
it("interpolates linearly between ranks (PERCENTILE.INC)", () => {
|
||||
const s = [1, 2, 3, 4, 5];
|
||||
expect(quantileSorted(s, 0)).toBe(1);
|
||||
expect(quantileSorted(s, 0.5)).toBe(3);
|
||||
expect(quantileSorted(s, 0.25)).toBe(2);
|
||||
expect(quantileSorted([10, 20], 0.5)).toBe(15);
|
||||
expect(quantileSorted([10, 20, 30, 40], 0.9)).toBeCloseTo(37);
|
||||
});
|
||||
|
||||
it("refuses a baseline below the day floor", () => {
|
||||
expect(baselineStats(Array(MIN_BASELINE_DAYS - 1).fill(5000))).toBeNull();
|
||||
const stats = baselineStats(
|
||||
Array.from({ length: 20 }, (_, i) => (i + 1) * 1000),
|
||||
);
|
||||
expect(stats?.days).toBe(20);
|
||||
expect(stats?.median).toBe(10500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("day level against the person's own spread", () => {
|
||||
const stats = baselineStats(Array.from({ length: 100 }, (_, i) => i))!;
|
||||
it.each([
|
||||
[null, "none"],
|
||||
[5, "low"],
|
||||
[20, "below"],
|
||||
[50, "usual"],
|
||||
[80, "above"],
|
||||
[95, "high"],
|
||||
] as const)("%s → %s", (value, level) => {
|
||||
expect(dayLevel(value, stats)).toBe(level);
|
||||
});
|
||||
|
||||
it("is unrated without a baseline, not guessed", () => {
|
||||
expect(dayLevel(9000, null)).toBe("unrated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("active minutes never double-count a workout", () => {
|
||||
it("takes the larger of exercise and workout minutes", () => {
|
||||
expect(
|
||||
activeMinutesOf(
|
||||
day("2026-05-01", { exerciseMinutes: 40, workoutMinutes: 30 }),
|
||||
true,
|
||||
),
|
||||
).toBe(40);
|
||||
expect(
|
||||
activeMinutesOf(
|
||||
day("2026-05-01", { exerciseMinutes: 10, workoutMinutes: 45 }),
|
||||
true,
|
||||
),
|
||||
).toBe(45);
|
||||
});
|
||||
it("is 0 on a quiet day once the account has activity data, otherwise unknown", () => {
|
||||
expect(activeMinutesOf(day("2026-05-01"), true)).toBe(0);
|
||||
expect(activeMinutesOf(day("2026-05-01"), false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("period bounds", () => {
|
||||
it("weeks run Monday to Sunday", () => {
|
||||
// 2026-10-21 is a Wednesday.
|
||||
expect(weekStartOf("2026-10-21")).toBe("2026-10-19");
|
||||
expect(weekStartOf("2026-10-19")).toBe("2026-10-19");
|
||||
expect(weekStartOf("2026-10-25")).toBe("2026-10-19");
|
||||
const b = periodBounds("week", "2026-10-21");
|
||||
expect([b.start, b.end]).toEqual(["2026-10-19", "2026-10-25"]);
|
||||
expect(b.readStart).toBe(shiftDateKey("2026-10-19", -BASELINE_DAYS));
|
||||
});
|
||||
|
||||
it("a month grid covers whole weeks and reads six months back", () => {
|
||||
const b = periodBounds("month", "2026-02-14");
|
||||
expect([b.start, b.end]).toEqual(["2026-02-01", "2026-02-28"]);
|
||||
expect(b.gridStart).toBe("2026-01-26");
|
||||
expect(b.gridEnd).toBe("2026-03-01");
|
||||
expect(b.readStart).toBe("2025-08-01");
|
||||
});
|
||||
|
||||
it("month arithmetic handles length and year turns", () => {
|
||||
expect(daysInMonth("2024-02-01")).toBe(29);
|
||||
expect(daysInMonth("2026-02-01")).toBe(28);
|
||||
expect(shiftMonth("2026-01-01", -1)).toBe("2025-12-01");
|
||||
expect(shiftMonth("2026-12-01", 1)).toBe("2027-01-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("period against the usual period", () => {
|
||||
it("compares a finished week with the median of the twelve before", () => {
|
||||
const values = new Map<string, number>();
|
||||
// Twelve earlier weeks at 7 000 a day, this week at 10 000 a day.
|
||||
const bounds = periodBounds("week", "2026-10-05");
|
||||
for (let i = 1; i <= 84; i++)
|
||||
values.set(shiftDateKey(bounds.start, -i), 7000);
|
||||
for (let i = 0; i < 7; i++)
|
||||
values.set(shiftDateKey(bounds.start, i), 10000);
|
||||
const result = comparePeriod({
|
||||
bounds,
|
||||
today: "2026-10-21",
|
||||
valueOf: (d) => values.get(d) ?? null,
|
||||
mode: "sum",
|
||||
});
|
||||
expect(result.value).toBe(70000);
|
||||
expect(result.usual).toBe(49000);
|
||||
expect(result.usualToDate).toBe(49000);
|
||||
expect(result.referencePeriods).toBe(12);
|
||||
});
|
||||
|
||||
it("pro-rates the usual for a period still running", () => {
|
||||
const bounds = periodBounds("week", "2026-10-21");
|
||||
const values = new Map<string, number>();
|
||||
for (let i = 1; i <= 84; i++)
|
||||
values.set(shiftDateKey(bounds.start, -i), 1000);
|
||||
const result = comparePeriod({
|
||||
bounds,
|
||||
today: "2026-10-21", // Wednesday: three days elapsed
|
||||
valueOf: (d) =>
|
||||
values.get(d) ?? (d <= "2026-10-21" && d >= bounds.start ? 2000 : null),
|
||||
mode: "sum",
|
||||
});
|
||||
expect(result.value).toBe(6000);
|
||||
expect(result.usual).toBe(7000);
|
||||
expect(result.usualToDate).toBe(3000);
|
||||
});
|
||||
|
||||
it("compares months per day, so February meets March fairly", () => {
|
||||
const bounds = periodBounds("month", "2026-02-10");
|
||||
const result = comparePeriod({
|
||||
bounds,
|
||||
today: "2026-10-21",
|
||||
valueOf: (d) => (d < "2026-03-01" ? 100 : null),
|
||||
mode: "sum",
|
||||
});
|
||||
expect(result.value).toBe(2800);
|
||||
// Six earlier months at 100 a day → 28 days × 100.
|
||||
expect(result.usual).toBe(2800);
|
||||
});
|
||||
|
||||
it("gives no usual with fewer than three earlier periods of data", () => {
|
||||
const bounds = periodBounds("week", "2026-10-05");
|
||||
const result = comparePeriod({
|
||||
bounds,
|
||||
today: "2026-10-21",
|
||||
valueOf: (d) => (d >= shiftDateKey(bounds.start, -14) ? 500 : null),
|
||||
mode: "sum",
|
||||
});
|
||||
expect(result.referencePeriods).toBe(2);
|
||||
expect(result.usual).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("goals", () => {
|
||||
it("reports only goals that are set, for the period they belong to", () => {
|
||||
const goals = {
|
||||
...NO_ACTIVITY_GOALS,
|
||||
workoutsPerWeek: 3,
|
||||
stepsPerMonth: 200000,
|
||||
};
|
||||
expect(goalProgress("week", goals, { workouts: 4 })).toEqual([
|
||||
{ key: "workoutsPerWeek", target: 3, value: 4, met: true },
|
||||
]);
|
||||
expect(goalProgress("month", goals, { steps: 150000 })).toEqual([
|
||||
{ key: "stepsPerMonth", target: 200000, value: 150000, met: false },
|
||||
]);
|
||||
expect(goalProgress("week", NO_ACTIVITY_GOALS, { workouts: 9 })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the assembled calendar", () => {
|
||||
const today = "2026-10-21";
|
||||
const bounds = periodBounds("month", today);
|
||||
// 84 baseline days of 5 000–9 000 steps, a run every Monday.
|
||||
const input: ActivityDayInput[] = [];
|
||||
for (let i = 1; i <= 200; i++) {
|
||||
const date = shiftDateKey(bounds.start, -i);
|
||||
input.push(
|
||||
day(date, {
|
||||
steps: 5000 + (i % 5) * 1000,
|
||||
workoutCount: i % 7 === 0 ? 1 : 0,
|
||||
workoutMinutes: i % 7 === 0 ? 30 : 0,
|
||||
workoutsBySport: i % 7 === 0 ? { running: 1 } : {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
input.push(
|
||||
day("2026-10-02", {
|
||||
steps: 15000,
|
||||
workoutCount: 2,
|
||||
workoutMinutes: 90,
|
||||
workoutsBySport: { running: 1, cycling: 1 },
|
||||
}),
|
||||
);
|
||||
input.push(day("2026-10-03", { steps: 2000 }));
|
||||
const calendar = buildActivityCalendar({
|
||||
bounds,
|
||||
today,
|
||||
input,
|
||||
goals: { ...NO_ACTIVITY_GOALS, workoutsPerWeek: 2 },
|
||||
});
|
||||
|
||||
it("draws whole weeks and marks the days after today as future", () => {
|
||||
expect(calendar.days[0].date).toBe(calendar.gridStart);
|
||||
expect(calendar.days.length % 7).toBe(0);
|
||||
const future = calendar.days.find((d) => d.date === "2026-10-22");
|
||||
expect(future?.future).toBe(true);
|
||||
expect(future?.level.steps).toBe("none");
|
||||
});
|
||||
|
||||
it("rates a big day high and a short one low against the person's own spread", () => {
|
||||
expect(calendar.baseline.steps?.days).toBe(BASELINE_DAYS);
|
||||
expect(
|
||||
calendar.days.find((d) => d.date === "2026-10-02")?.level.steps,
|
||||
).toBe("high");
|
||||
expect(
|
||||
calendar.days.find((d) => d.date === "2026-10-03")?.level.steps,
|
||||
).toBe("low");
|
||||
expect(
|
||||
calendar.days.find((d) => d.date === "2026-10-04")?.level.steps,
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("counts workouts by sport in the period", () => {
|
||||
expect(calendar.sports).toEqual([
|
||||
{ sportType: "cycling", count: 1 },
|
||||
{ sportType: "running", count: 1 },
|
||||
]);
|
||||
expect(calendar.summary.workouts.value).toBe(2);
|
||||
});
|
||||
|
||||
it("marks weekly goals per calendar row, and none for a week not yet begun", () => {
|
||||
const week = calendar.weeks.find((w) => w.start === "2026-09-28");
|
||||
expect(week?.goals).toEqual([
|
||||
{ key: "workoutsPerWeek", target: 2, value: 2, met: true },
|
||||
]);
|
||||
const last = calendar.weeks[calendar.weeks.length - 1];
|
||||
expect(last.start > today).toBe(true);
|
||||
expect(last.goals).toEqual([]);
|
||||
expect(calendar.next).toBeNull();
|
||||
expect(calendar.previous).toBe("2026-09-01");
|
||||
});
|
||||
|
||||
it("offers only the signals the account has", () => {
|
||||
expect(calendar.available).toEqual({
|
||||
steps: true,
|
||||
activeMinutes: true,
|
||||
workouts: true,
|
||||
load: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Activity calendar arithmetic (D1036). Pure: no database, no clock, no zone.
|
||||
*
|
||||
* The calendar compares a week or a month of activity with the person's own
|
||||
* normal, never with a population norm. Two comparisons, each with its own
|
||||
* honest reference:
|
||||
*
|
||||
* - A DAY is placed against the spread of the person's own days in the 84
|
||||
* days before the period starts (`BASELINE_DAYS`): below the 10th
|
||||
* percentile is `low`, below the 25th `below`, above the 75th `above`,
|
||||
* above the 90th `high`, everything between `usual`. With fewer than
|
||||
* `MIN_BASELINE_DAYS` days that carry a value the day is `unrated`:
|
||||
* there is not enough history to say what usual is, and the calendar says
|
||||
* so instead of guessing.
|
||||
* - A PERIOD total is placed against the median total of the same kind of
|
||||
* period before it: the twelve weeks before a week, the six months before
|
||||
* a month. A month is compared per day (months differ in length), so the
|
||||
* usual month is the median daily rate of the earlier months times this
|
||||
* month's length. A period still running is compared with the usual rate
|
||||
* times the days elapsed so far, not with a full period it cannot have
|
||||
* reached yet. At least `MIN_REFERENCE_PERIODS` earlier periods with data
|
||||
* are needed; fewer gives no usual.
|
||||
*
|
||||
* What a day's value is:
|
||||
* - steps: the day's step total (the same canonical-source sum the steps
|
||||
* chart draws); a day without a step reading has no value, it is not 0,
|
||||
* because a watch left on the charger is not a day without steps.
|
||||
* - active minutes: the larger of the day's exercise minutes and the day's
|
||||
* workout minutes. Exercise minutes already contain the workouts a watch
|
||||
* recorded, so adding the two would count a workout twice; taking the
|
||||
* larger never does, and still counts a workout logged by hand on a day
|
||||
* the watch recorded nothing. Once the account has any activity data in
|
||||
* the window, a day without either is 0.
|
||||
* - workouts: the number of workouts that started that day, after the
|
||||
* cross-source duplicate collapse the workout list applies. Shown as
|
||||
* counts, never rated: most days have none, so percentiles of a mostly-zero
|
||||
* series say nothing.
|
||||
* - load: the day's training-load score when a device or HealthLog computes
|
||||
* one; no value otherwise.
|
||||
*/
|
||||
import {
|
||||
daysBetweenDateKeys,
|
||||
shiftDateKey,
|
||||
weekdayOfDateKey,
|
||||
} from "@/lib/tz/format";
|
||||
|
||||
import type {
|
||||
ActivityGoalKey,
|
||||
ActivityGoals,
|
||||
} from "@/lib/validations/activity-goals";
|
||||
|
||||
/** Days before the period whose spread defines a usual day. */
|
||||
export const BASELINE_DAYS = 84;
|
||||
/** Days with a value needed before a day is rated against the baseline. */
|
||||
export const MIN_BASELINE_DAYS = 14;
|
||||
/** Earlier periods compared against: twelve weeks, six months. */
|
||||
export const REFERENCE_WEEKS = 12;
|
||||
export const REFERENCE_MONTHS = 6;
|
||||
/** Earlier periods with data needed before a usual period total is given. */
|
||||
export const MIN_REFERENCE_PERIODS = 3;
|
||||
|
||||
export type ActivityPeriod = "week" | "month";
|
||||
export type RatedMetric = "steps" | "activeMinutes" | "load";
|
||||
export type DayLevel =
|
||||
"none" | "unrated" | "low" | "below" | "usual" | "above" | "high";
|
||||
|
||||
/** One local day of raw signals, as the reader assembles it. */
|
||||
export interface ActivityDayInput {
|
||||
/** Local calendar day, `YYYY-MM-DD`. */
|
||||
date: string;
|
||||
steps: number | null;
|
||||
exerciseMinutes: number | null;
|
||||
workoutMinutes: number;
|
||||
workoutCount: number;
|
||||
workoutsBySport: Record<string, number>;
|
||||
load: number | null;
|
||||
}
|
||||
|
||||
export interface BaselineStats {
|
||||
median: number;
|
||||
p10: number;
|
||||
p25: number;
|
||||
p75: number;
|
||||
p90: number;
|
||||
/** Days with a value the stats were computed from. */
|
||||
days: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear-interpolation quantile (the R-7 / spreadsheet `PERCENTILE.INC`
|
||||
* definition) over an ascending-sorted array. `q` in [0, 1].
|
||||
*/
|
||||
export function quantileSorted(sorted: readonly number[], q: number): number {
|
||||
if (sorted.length === 0) return Number.NaN;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const pos = (sorted.length - 1) * q;
|
||||
const lo = Math.floor(pos);
|
||||
const hi = Math.ceil(pos);
|
||||
if (lo === hi) return sorted[lo];
|
||||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
|
||||
}
|
||||
|
||||
export function median(values: readonly number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return quantileSorted(sorted, 0.5);
|
||||
}
|
||||
|
||||
export function baselineStats(values: readonly number[]): BaselineStats | null {
|
||||
if (values.length < MIN_BASELINE_DAYS) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return {
|
||||
median: quantileSorted(sorted, 0.5),
|
||||
p10: quantileSorted(sorted, 0.1),
|
||||
p25: quantileSorted(sorted, 0.25),
|
||||
p75: quantileSorted(sorted, 0.75),
|
||||
p90: quantileSorted(sorted, 0.9),
|
||||
days: sorted.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Where one day's value sits in the person's own spread. */
|
||||
export function dayLevel(
|
||||
value: number | null,
|
||||
baseline: BaselineStats | null,
|
||||
): DayLevel {
|
||||
if (value === null) return "none";
|
||||
if (!baseline) return "unrated";
|
||||
if (value < baseline.p10) return "low";
|
||||
if (value < baseline.p25) return "below";
|
||||
if (value > baseline.p90) return "high";
|
||||
if (value > baseline.p75) return "above";
|
||||
return "usual";
|
||||
}
|
||||
|
||||
/** The active-minutes rule from the header: the larger, never the sum. */
|
||||
export function activeMinutesOf(
|
||||
day: ActivityDayInput,
|
||||
hasActivityData: boolean,
|
||||
): number | null {
|
||||
if (day.exerciseMinutes !== null) {
|
||||
return Math.max(day.exerciseMinutes, day.workoutMinutes);
|
||||
}
|
||||
if (day.workoutMinutes > 0) return day.workoutMinutes;
|
||||
return hasActivityData ? 0 : null;
|
||||
}
|
||||
|
||||
export function metricValue(
|
||||
day: ActivityDayInput,
|
||||
metric: RatedMetric,
|
||||
hasActivityData: boolean,
|
||||
): number | null {
|
||||
switch (metric) {
|
||||
case "steps":
|
||||
return day.steps;
|
||||
case "activeMinutes":
|
||||
return activeMinutesOf(day, hasActivityData);
|
||||
case "load":
|
||||
return day.load;
|
||||
}
|
||||
}
|
||||
|
||||
/** Monday on or before `date` (the calendar's weeks run Monday to Sunday). */
|
||||
export function weekStartOf(date: string): string {
|
||||
const weekday = weekdayOfDateKey(date); // 0 = Sunday
|
||||
return shiftDateKey(date, -((weekday + 6) % 7));
|
||||
}
|
||||
|
||||
export function monthStartOf(date: string): string {
|
||||
return `${date.slice(0, 7)}-01`;
|
||||
}
|
||||
|
||||
export function daysInMonth(monthStart: string): number {
|
||||
const [y, m] = monthStart.split("-").map(Number);
|
||||
return new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
/** First day of the month `delta` months from `monthStart`. */
|
||||
export function shiftMonth(monthStart: string, delta: number): string {
|
||||
const [y, m] = monthStart.split("-").map(Number);
|
||||
const index = y * 12 + (m - 1) + delta;
|
||||
const year = Math.floor(index / 12);
|
||||
const month = (index % 12) + 1;
|
||||
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-01`;
|
||||
}
|
||||
|
||||
export interface PeriodBounds {
|
||||
period: ActivityPeriod;
|
||||
/** First and last day of the period, inclusive. */
|
||||
start: string;
|
||||
end: string;
|
||||
/** The grid the calendar draws: whole weeks around the period. */
|
||||
gridStart: string;
|
||||
gridEnd: string;
|
||||
/** The earliest day any comparison reads. */
|
||||
readStart: string;
|
||||
}
|
||||
|
||||
export function periodBounds(
|
||||
period: ActivityPeriod,
|
||||
anchor: string,
|
||||
): PeriodBounds {
|
||||
if (period === "week") {
|
||||
const start = weekStartOf(anchor);
|
||||
const end = shiftDateKey(start, 6);
|
||||
return {
|
||||
period,
|
||||
start,
|
||||
end,
|
||||
gridStart: start,
|
||||
gridEnd: end,
|
||||
readStart: shiftDateKey(
|
||||
start,
|
||||
-Math.max(BASELINE_DAYS, REFERENCE_WEEKS * 7),
|
||||
),
|
||||
};
|
||||
}
|
||||
const start = monthStartOf(anchor);
|
||||
const end = shiftDateKey(start, daysInMonth(start) - 1);
|
||||
const gridStart = weekStartOf(start);
|
||||
const gridEnd = shiftDateKey(weekStartOf(end), 6);
|
||||
// Date keys order lexically, so the earliest is the smallest string.
|
||||
const readStart = [
|
||||
shiftMonth(start, -REFERENCE_MONTHS),
|
||||
shiftDateKey(start, -BASELINE_DAYS),
|
||||
gridStart,
|
||||
].sort()[0];
|
||||
return { period, start, end, gridStart, gridEnd, readStart };
|
||||
}
|
||||
|
||||
/** Every day key from `from` to `to`, inclusive. */
|
||||
export function dayRange(from: string, to: string): string[] {
|
||||
const n = daysBetweenDateKeys(from, to);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i <= n; i++) out.push(shiftDateKey(from, i));
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface PeriodComparison {
|
||||
/** The period's total (steps, active minutes, workouts) or mean (load), over the days so far. */
|
||||
value: number | null;
|
||||
/** Days of the period that carry a value. */
|
||||
daysWithData: number;
|
||||
/** The usual full period, or null when there is not enough history. */
|
||||
usual: number | null;
|
||||
/** The usual figure for the days elapsed so far (equals `usual` for a finished period). */
|
||||
usualToDate: number | null;
|
||||
/** Earlier periods the usual was computed from. */
|
||||
referencePeriods: number;
|
||||
}
|
||||
|
||||
export type SummaryMetric = RatedMetric | "workouts";
|
||||
|
||||
function aggregate(
|
||||
values: ReadonlyArray<number | null>,
|
||||
mode: "sum" | "mean",
|
||||
): { value: number | null; n: number } {
|
||||
const present = values.filter((v): v is number => v !== null);
|
||||
if (present.length === 0) return { value: null, n: 0 };
|
||||
const total = present.reduce((a, b) => a + b, 0);
|
||||
return {
|
||||
value: mode === "sum" ? total : total / present.length,
|
||||
n: present.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** The earlier periods a period is compared with, as inclusive day ranges. */
|
||||
export function referencePeriods(
|
||||
bounds: Pick<PeriodBounds, "period" | "start">,
|
||||
): Array<{ start: string; end: string; length: number }> {
|
||||
const out: Array<{ start: string; end: string; length: number }> = [];
|
||||
if (bounds.period === "week") {
|
||||
for (let i = 1; i <= REFERENCE_WEEKS; i++) {
|
||||
const start = shiftDateKey(bounds.start, -7 * i);
|
||||
out.push({ start, end: shiftDateKey(start, 6), length: 7 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for (let i = 1; i <= REFERENCE_MONTHS; i++) {
|
||||
const start = shiftMonth(bounds.start, -i);
|
||||
const length = daysInMonth(start);
|
||||
out.push({ start, end: shiftDateKey(start, length - 1), length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function comparePeriod(input: {
|
||||
bounds: Pick<PeriodBounds, "period" | "start" | "end">;
|
||||
/** The last day that has happened (today in the person's zone). */
|
||||
today: string;
|
||||
valueOf: (date: string) => number | null;
|
||||
mode: "sum" | "mean";
|
||||
}): PeriodComparison {
|
||||
const { bounds, today, valueOf, mode } = input;
|
||||
const lastCounted = bounds.end < today ? bounds.end : today;
|
||||
const length = daysBetweenDateKeys(bounds.start, bounds.end) + 1;
|
||||
const elapsed =
|
||||
lastCounted < bounds.start
|
||||
? 0
|
||||
: daysBetweenDateKeys(bounds.start, lastCounted) + 1;
|
||||
const current =
|
||||
elapsed === 0
|
||||
? { value: null, n: 0 }
|
||||
: aggregate(dayRange(bounds.start, lastCounted).map(valueOf), mode);
|
||||
|
||||
// Each earlier period becomes a per-day rate (sum) or a mean (mean), so a
|
||||
// 28-day February and a 31-day March compare on the same footing.
|
||||
const rates: number[] = [];
|
||||
for (const ref of referencePeriods(bounds)) {
|
||||
const agg = aggregate(dayRange(ref.start, ref.end).map(valueOf), mode);
|
||||
if (agg.value === null) continue;
|
||||
rates.push(mode === "sum" ? agg.value / ref.length : agg.value);
|
||||
}
|
||||
const usualRate =
|
||||
rates.length >= MIN_REFERENCE_PERIODS ? median(rates) : null;
|
||||
const usual =
|
||||
usualRate === null ? null : mode === "sum" ? usualRate * length : usualRate;
|
||||
const usualToDate =
|
||||
usualRate === null
|
||||
? null
|
||||
: mode === "sum"
|
||||
? usualRate * elapsed
|
||||
: usualRate;
|
||||
return {
|
||||
value: current.value,
|
||||
daysWithData: current.n,
|
||||
usual,
|
||||
usualToDate: elapsed === 0 ? null : usualToDate,
|
||||
referencePeriods: rates.length,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GoalProgress {
|
||||
key: ActivityGoalKey;
|
||||
target: number;
|
||||
value: number;
|
||||
met: boolean;
|
||||
}
|
||||
|
||||
/** Which goals apply to which period, and which figure each one reads. */
|
||||
const GOALS_BY_PERIOD: Record<
|
||||
ActivityPeriod,
|
||||
Array<{ key: ActivityGoalKey; metric: SummaryMetric }>
|
||||
> = {
|
||||
week: [
|
||||
{ key: "workoutsPerWeek", metric: "workouts" },
|
||||
{ key: "activeMinutesPerWeek", metric: "activeMinutes" },
|
||||
{ key: "stepsPerWeek", metric: "steps" },
|
||||
],
|
||||
month: [{ key: "stepsPerMonth", metric: "steps" }],
|
||||
};
|
||||
|
||||
export function goalProgress(
|
||||
period: ActivityPeriod,
|
||||
goals: ActivityGoals,
|
||||
totals: Partial<Record<SummaryMetric, number | null>>,
|
||||
): GoalProgress[] {
|
||||
const out: GoalProgress[] = [];
|
||||
for (const { key, metric } of GOALS_BY_PERIOD[period]) {
|
||||
const target = goals[key];
|
||||
if (target === null) continue;
|
||||
const value = totals[metric] ?? 0;
|
||||
out.push({ key, target, value, met: value >= target });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
date: string;
|
||||
inPeriod: boolean;
|
||||
future: boolean;
|
||||
steps: number | null;
|
||||
activeMinutes: number | null;
|
||||
workoutCount: number;
|
||||
workoutsBySport: Record<string, number>;
|
||||
load: number | null;
|
||||
level: Record<RatedMetric, DayLevel>;
|
||||
}
|
||||
|
||||
export interface CalendarWeekRow {
|
||||
start: string;
|
||||
end: string;
|
||||
steps: number | null;
|
||||
activeMinutes: number | null;
|
||||
workouts: number;
|
||||
/** The weekly goals, met or not, for a week that has started. */
|
||||
goals: GoalProgress[];
|
||||
}
|
||||
|
||||
export interface ActivityCalendar {
|
||||
period: ActivityPeriod;
|
||||
start: string;
|
||||
end: string;
|
||||
gridStart: string;
|
||||
gridEnd: string;
|
||||
today: string;
|
||||
days: CalendarDay[];
|
||||
weeks: CalendarWeekRow[];
|
||||
baseline: Record<RatedMetric, BaselineStats | null>;
|
||||
summary: Record<SummaryMetric, PeriodComparison>;
|
||||
sports: Array<{ sportType: string; count: number }>;
|
||||
goals: ActivityGoals;
|
||||
goalProgress: GoalProgress[];
|
||||
available: Record<SummaryMetric, boolean>;
|
||||
previous: string;
|
||||
next: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the calendar from per-day inputs covering at least
|
||||
* `bounds.readStart`..`bounds.gridEnd`. Days missing from `input` count as
|
||||
* days without any reading.
|
||||
*/
|
||||
export function buildActivityCalendar(args: {
|
||||
bounds: PeriodBounds;
|
||||
today: string;
|
||||
input: readonly ActivityDayInput[];
|
||||
goals: ActivityGoals;
|
||||
}): ActivityCalendar {
|
||||
const { bounds, today, input, goals } = args;
|
||||
const byDate = new Map(input.map((d) => [d.date, d]));
|
||||
const empty = (date: string): ActivityDayInput => ({
|
||||
date,
|
||||
steps: null,
|
||||
exerciseMinutes: null,
|
||||
workoutMinutes: 0,
|
||||
workoutCount: 0,
|
||||
workoutsBySport: {},
|
||||
load: null,
|
||||
});
|
||||
const dayOf = (date: string) => byDate.get(date) ?? empty(date);
|
||||
|
||||
// The first day with any activity signal: from there on, a day without
|
||||
// workouts or exercise minutes is a day of 0 active minutes, not a gap.
|
||||
const firstActivityDay =
|
||||
input
|
||||
.filter(
|
||||
(d) =>
|
||||
d.exerciseMinutes !== null ||
|
||||
d.workoutCount > 0 ||
|
||||
d.workoutMinutes > 0,
|
||||
)
|
||||
.map((d) => d.date)
|
||||
.sort()[0] ?? null;
|
||||
const hasActivityOn = (date: string) =>
|
||||
firstActivityDay !== null && date >= firstActivityDay && date <= today;
|
||||
|
||||
const valueOf = (metric: SummaryMetric) => (date: string) => {
|
||||
if (date > today) return null;
|
||||
const day = dayOf(date);
|
||||
if (metric === "workouts")
|
||||
return hasActivityOn(date) ? day.workoutCount : null;
|
||||
return metricValue(day, metric, hasActivityOn(date));
|
||||
};
|
||||
|
||||
const baselineFrom = shiftDateKey(bounds.start, -BASELINE_DAYS);
|
||||
const baselineTo = shiftDateKey(bounds.start, -1);
|
||||
const baselineDays = dayRange(baselineFrom, baselineTo);
|
||||
const ratedMetrics: RatedMetric[] = ["steps", "activeMinutes", "load"];
|
||||
const baseline = Object.fromEntries(
|
||||
ratedMetrics.map((metric) => [
|
||||
metric,
|
||||
baselineStats(
|
||||
baselineDays
|
||||
.map(valueOf(metric))
|
||||
.filter((v): v is number => v !== null),
|
||||
),
|
||||
]),
|
||||
) as Record<RatedMetric, BaselineStats | null>;
|
||||
|
||||
const days: CalendarDay[] = dayRange(bounds.gridStart, bounds.gridEnd).map(
|
||||
(date) => {
|
||||
const day = dayOf(date);
|
||||
const future = date > today;
|
||||
const steps = future ? null : day.steps;
|
||||
const activeMinutes = future
|
||||
? null
|
||||
: activeMinutesOf(day, hasActivityOn(date));
|
||||
const load = future ? null : day.load;
|
||||
return {
|
||||
date,
|
||||
inPeriod: date >= bounds.start && date <= bounds.end,
|
||||
future,
|
||||
steps,
|
||||
activeMinutes,
|
||||
workoutCount: future ? 0 : day.workoutCount,
|
||||
workoutsBySport: future ? {} : day.workoutsBySport,
|
||||
load,
|
||||
level: {
|
||||
steps: dayLevel(steps, baseline.steps),
|
||||
activeMinutes: dayLevel(activeMinutes, baseline.activeMinutes),
|
||||
load: dayLevel(load, baseline.load),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const summary: Record<SummaryMetric, PeriodComparison> = {
|
||||
steps: comparePeriod({
|
||||
bounds,
|
||||
today,
|
||||
valueOf: valueOf("steps"),
|
||||
mode: "sum",
|
||||
}),
|
||||
activeMinutes: comparePeriod({
|
||||
bounds,
|
||||
today,
|
||||
valueOf: valueOf("activeMinutes"),
|
||||
mode: "sum",
|
||||
}),
|
||||
workouts: comparePeriod({
|
||||
bounds,
|
||||
today,
|
||||
valueOf: valueOf("workouts"),
|
||||
mode: "sum",
|
||||
}),
|
||||
load: comparePeriod({
|
||||
bounds,
|
||||
today,
|
||||
valueOf: valueOf("load"),
|
||||
mode: "mean",
|
||||
}),
|
||||
};
|
||||
|
||||
const sportCounts = new Map<string, number>();
|
||||
for (const date of dayRange(bounds.start, bounds.end)) {
|
||||
if (date > today) break;
|
||||
for (const [sport, count] of Object.entries(dayOf(date).workoutsBySport)) {
|
||||
sportCounts.set(sport, (sportCounts.get(sport) ?? 0) + count);
|
||||
}
|
||||
}
|
||||
const sports = [...sportCounts]
|
||||
.map(([sportType, count]) => ({ sportType, count }))
|
||||
.sort(
|
||||
(a, b) => b.count - a.count || a.sportType.localeCompare(b.sportType),
|
||||
);
|
||||
|
||||
const weeks: CalendarWeekRow[] = [];
|
||||
for (
|
||||
let start = bounds.gridStart;
|
||||
start <= bounds.gridEnd;
|
||||
start = shiftDateKey(start, 7)
|
||||
) {
|
||||
const end = shiftDateKey(start, 6);
|
||||
const range = dayRange(start, end);
|
||||
const sum = (metric: SummaryMetric) =>
|
||||
aggregate(range.map(valueOf(metric)), "sum").value;
|
||||
const row = {
|
||||
start,
|
||||
end,
|
||||
steps: sum("steps"),
|
||||
activeMinutes: sum("activeMinutes"),
|
||||
workouts: sum("workouts") ?? 0,
|
||||
};
|
||||
weeks.push({
|
||||
...row,
|
||||
goals:
|
||||
start > today
|
||||
? []
|
||||
: goalProgress("week", goals, {
|
||||
steps: row.steps,
|
||||
activeMinutes: row.activeMinutes,
|
||||
workouts: row.workouts,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const progress = goalProgress(bounds.period, goals, {
|
||||
steps: summary.steps.value,
|
||||
activeMinutes: summary.activeMinutes.value,
|
||||
workouts: summary.workouts.value,
|
||||
});
|
||||
|
||||
const anyIn = (pick: (d: ActivityDayInput) => boolean) => input.some(pick);
|
||||
const available: Record<SummaryMetric, boolean> = {
|
||||
steps: anyIn((d) => d.steps !== null),
|
||||
activeMinutes: firstActivityDay !== null,
|
||||
workouts: anyIn((d) => d.workoutCount > 0),
|
||||
load: anyIn((d) => d.load !== null),
|
||||
};
|
||||
|
||||
const previous =
|
||||
bounds.period === "week"
|
||||
? shiftDateKey(bounds.start, -7)
|
||||
: shiftMonth(bounds.start, -1);
|
||||
const nextStart =
|
||||
bounds.period === "week"
|
||||
? shiftDateKey(bounds.start, 7)
|
||||
: shiftMonth(bounds.start, 1);
|
||||
|
||||
return {
|
||||
period: bounds.period,
|
||||
start: bounds.start,
|
||||
end: bounds.end,
|
||||
gridStart: bounds.gridStart,
|
||||
gridEnd: bounds.gridEnd,
|
||||
today,
|
||||
days,
|
||||
weeks,
|
||||
baseline,
|
||||
summary,
|
||||
sports,
|
||||
goals,
|
||||
goalProgress: progress,
|
||||
available,
|
||||
previous,
|
||||
next: nextStart > today ? null : nextStart,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Database side of the activity calendar (D1036): per local day, the step
|
||||
* total, the exercise minutes, the training load and the workouts, over the
|
||||
* window `periodBounds` asks for. The arithmetic lives in `calendar-math.ts`.
|
||||
*
|
||||
* Steps follow the same rule as the steps chart: each UTC day resolves to the
|
||||
* source the person's priority ladder picks (`seriesRowsFrom`, the helper the
|
||||
* series route reads through), and the picked rows are then cut at the
|
||||
* person's own midnight. A calendar day and the chart's day therefore show
|
||||
* the same number. Workouts go through the cross-source collapse the workout
|
||||
* list applies, so a session recorded by two devices counts once.
|
||||
*
|
||||
* Bounded: four aggregate queries returning at most one row per day of the
|
||||
* window (about 220 days for a month), plus the workouts of that window.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
import type { ActivityDayInput } from "./calendar-math";
|
||||
|
||||
/**
|
||||
* Training-load types in the order the calendar prefers them: a device's own
|
||||
* daily figure first (WHOOP day strain, Polar cardio load), HealthLog's
|
||||
* computed strain score last. The first type with any reading in the window
|
||||
* is the one shown; two scales are never mixed on one calendar.
|
||||
*/
|
||||
export const LOAD_TYPES: readonly MeasurementType[] = [
|
||||
"DAY_STRAIN",
|
||||
"CARDIO_LOAD",
|
||||
"STRAIN_SCORE",
|
||||
];
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
interface DayAggregateRow {
|
||||
day: string;
|
||||
total: number;
|
||||
/** Readings that day; the load is their plain mean (one score a day, as a rule). */
|
||||
n: number;
|
||||
}
|
||||
|
||||
async function dailyAggregate(
|
||||
userId: string,
|
||||
tz: string,
|
||||
priorityJson: unknown,
|
||||
type: MeasurementType,
|
||||
from: Date,
|
||||
to: Date,
|
||||
now: Date,
|
||||
): Promise<Map<string, DayAggregateRow>> {
|
||||
// `seriesRowsFrom` scopes its canonical pick to a trailing window counted
|
||||
// from now; it is widened by a day so the edge day's source is picked from
|
||||
// all of its readings, and the outer filter trims to the exact window.
|
||||
const days = Math.min(
|
||||
3649,
|
||||
Math.max(1, Math.ceil((now.getTime() - from.getTime()) / DAY_MS) + 1),
|
||||
);
|
||||
const rows = await prisma.$queryRawUnsafe<DayAggregateRow[]>(
|
||||
`
|
||||
SELECT
|
||||
to_char(
|
||||
date_trunc('day', (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $2),
|
||||
'YYYY-MM-DD'
|
||||
) AS day,
|
||||
SUM(m."value")::double precision AS total,
|
||||
COUNT(*)::int AS n
|
||||
FROM ${seriesRowsFrom(priorityJson, type, days)}
|
||||
WHERE m."measured_at" >= $3
|
||||
AND m."measured_at" < $4
|
||||
GROUP BY 1
|
||||
`,
|
||||
userId,
|
||||
tz,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
return new Map(rows.map((r) => [r.day, r]));
|
||||
}
|
||||
|
||||
function meanOf(row: DayAggregateRow | undefined): number | null {
|
||||
return row && row.n > 0 ? row.total / row.n : null;
|
||||
}
|
||||
|
||||
export interface ActivityReadResult {
|
||||
days: ActivityDayInput[];
|
||||
loadType: MeasurementType | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `fromKey`..`toKey` (inclusive local days) for one account.
|
||||
* `today` caps the read: nothing after the person's today is fetched.
|
||||
*/
|
||||
export async function readActivityDays(args: {
|
||||
userId: string;
|
||||
tz: string;
|
||||
fromKey: string;
|
||||
toKey: string;
|
||||
now?: Date;
|
||||
includeWorkouts: boolean;
|
||||
}): Promise<ActivityReadResult> {
|
||||
const { userId, tz, fromKey, toKey, includeWorkouts } = args;
|
||||
const now = args.now ?? new Date();
|
||||
const from = startOfLocalDayKey(fromKey, tz);
|
||||
const to = startOfLocalDayKey(shiftDateKey(toKey, 1), tz);
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
|
||||
const [steps, exercise, loads, workoutRows] = await Promise.all([
|
||||
dailyAggregate(userId, tz, priorityJson, "ACTIVITY_STEPS", from, to, now),
|
||||
dailyAggregate(userId, tz, priorityJson, "EXERCISE_MINUTES", from, to, now),
|
||||
Promise.all(
|
||||
LOAD_TYPES.map((type) =>
|
||||
dailyAggregate(userId, tz, priorityJson, type, from, to, now),
|
||||
),
|
||||
),
|
||||
includeWorkouts
|
||||
? prisma.workout.findMany({
|
||||
where: { userId, startedAt: { gte: from, lt: to } },
|
||||
select: {
|
||||
startedAt: true,
|
||||
durationSec: true,
|
||||
sportType: true,
|
||||
source: true,
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
metadata: true,
|
||||
},
|
||||
orderBy: { startedAt: "asc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const loadIndex = loads.findIndex((m) => m.size > 0);
|
||||
const load = loadIndex >= 0 ? loads[loadIndex] : null;
|
||||
|
||||
const workouts = pickCanonicalWorkoutRows(workoutRows, priorityJson);
|
||||
const workoutsByDay = new Map<
|
||||
string,
|
||||
{ minutes: number; count: number; bySport: Record<string, number> }
|
||||
>();
|
||||
for (const w of workouts) {
|
||||
const key = userDayKey(w.startedAt, tz);
|
||||
const slot = workoutsByDay.get(key) ?? {
|
||||
minutes: 0,
|
||||
count: 0,
|
||||
bySport: {},
|
||||
};
|
||||
slot.minutes += Math.max(0, w.durationSec) / 60;
|
||||
slot.count += 1;
|
||||
slot.bySport[w.sportType] = (slot.bySport[w.sportType] ?? 0) + 1;
|
||||
workoutsByDay.set(key, slot);
|
||||
}
|
||||
|
||||
const keys = new Set<string>([
|
||||
...steps.keys(),
|
||||
...exercise.keys(),
|
||||
...(load ? load.keys() : []),
|
||||
...workoutsByDay.keys(),
|
||||
]);
|
||||
const days: ActivityDayInput[] = [...keys]
|
||||
.filter((k) => k >= fromKey && k <= toKey)
|
||||
.sort()
|
||||
.map((date) => {
|
||||
const w = workoutsByDay.get(date);
|
||||
return {
|
||||
date,
|
||||
steps: steps.get(date)?.total ?? null,
|
||||
exerciseMinutes: exercise.get(date)?.total ?? null,
|
||||
workoutMinutes: w ? Math.round(w.minutes) : 0,
|
||||
workoutCount: w?.count ?? 0,
|
||||
workoutsBySport: w?.bySport ?? {},
|
||||
load: meanOf(load?.get(date)),
|
||||
};
|
||||
});
|
||||
|
||||
return { days, loadType: loadIndex >= 0 ? LOAD_TYPES[loadIndex] : null };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The activity-goal keys and their bounds, without a schema library, so the
|
||||
* goals sheet can read them in the browser. The schema that enforces them
|
||||
* on the server is `src/lib/validations/activity-goals.ts`.
|
||||
*/
|
||||
export const ACTIVITY_GOAL_KEYS = [
|
||||
"workoutsPerWeek",
|
||||
"activeMinutesPerWeek",
|
||||
"stepsPerWeek",
|
||||
"stepsPerMonth",
|
||||
] as const;
|
||||
|
||||
export type ActivityGoalKey = (typeof ACTIVITY_GOAL_KEYS)[number];
|
||||
|
||||
/**
|
||||
* Wide on purpose: they refuse typing mistakes (a goal of 5 000 000 steps a
|
||||
* week), they do not judge what a sensible goal is.
|
||||
*/
|
||||
export const ACTIVITY_GOAL_BOUNDS: Record<
|
||||
ActivityGoalKey,
|
||||
{ min: number; max: number }
|
||||
> = {
|
||||
workoutsPerWeek: { min: 1, max: 28 },
|
||||
activeMinutesPerWeek: { min: 10, max: 5000 },
|
||||
stepsPerWeek: { min: 1000, max: 700_000 },
|
||||
stepsPerMonth: { min: 1000, max: 3_000_000 },
|
||||
};
|
||||
@@ -487,6 +487,7 @@ export const USER_RESET = {
|
||||
moodTagLayoutJson: Prisma.DbNull,
|
||||
documentsLayoutJson: Prisma.DbNull,
|
||||
moduleListLayoutJson: Prisma.DbNull,
|
||||
activityGoalsJson: Prisma.DbNull,
|
||||
reportSelectionJson: Prisma.DbNull,
|
||||
modulePreferencesJson: Prisma.DbNull,
|
||||
healthScoreConfigJson: Prisma.DbNull,
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { thresholdsUpdateSchema } from "@/lib/validations/thresholds";
|
||||
import { activityGoalsSchema } from "@/lib/validations/activity-goals";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import {
|
||||
glucoseUnitPatchSchema,
|
||||
@@ -267,6 +268,37 @@ const thresholds: SettingCodec = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The activity goals, goal by goal. A goal outside its bounds, or a key this
|
||||
* release does not know, is dropped and named; the valid goals beside it
|
||||
* still come back.
|
||||
*/
|
||||
const activityGoals: SettingCodec = {
|
||||
write: (row) => ({ activityGoalsJson: row.activityGoalsJson ?? null }),
|
||||
read: (entry) => {
|
||||
if (!has(entry, "activityGoalsJson")) return undefined;
|
||||
const value = entry.activityGoalsJson;
|
||||
if (value === null) return Prisma.DbNull;
|
||||
if (typeof value !== "object" || Array.isArray(value)) {
|
||||
return new Refused("activityGoalsJson");
|
||||
}
|
||||
const kept: Record<string, number> = {};
|
||||
const refused: string[] = [];
|
||||
for (const [key, goal] of Object.entries(value)) {
|
||||
const parsed = activityGoalsSchema.safeParse({ [key]: goal });
|
||||
if (!parsed.success) {
|
||||
refused.push(`activityGoalsJson.${key}`);
|
||||
} else if (typeof goal === "number") {
|
||||
kept[key] = goal;
|
||||
}
|
||||
}
|
||||
return new PartlyRefused(
|
||||
Object.keys(kept).length === 0 ? Prisma.DbNull : kept,
|
||||
refused,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const injectionSites = Object.values(InjectionSite) as string[];
|
||||
|
||||
/** The injection sites to avoid everywhere; an unknown site is dropped. */
|
||||
@@ -505,6 +537,7 @@ const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
documentsLayoutJson: json("documentsLayoutJson"),
|
||||
moduleListLayoutJson: json("moduleListLayoutJson"),
|
||||
previewFeaturesJson: json("previewFeaturesJson"),
|
||||
activityGoalsJson: activityGoals,
|
||||
reportSelectionJson: json("reportSelectionJson"),
|
||||
globalExcludedInjectionSites: excludedSites,
|
||||
healthKitConfigJson: json("healthKitConfigJson"),
|
||||
|
||||
@@ -1015,6 +1015,7 @@ export const USER_COLUMN_BACKUP_CLASS = {
|
||||
documentsLayoutJson: "SETTING",
|
||||
moduleListLayoutJson: "SETTING",
|
||||
previewFeaturesJson: "SETTING",
|
||||
activityGoalsJson: "SETTING",
|
||||
reportSelectionJson: "SETTING",
|
||||
globalExcludedInjectionSites: "SETTING",
|
||||
healthKitConfigJson: "SETTING",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
|
||||
*/
|
||||
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
{
|
||||
activity: ["activity.calendar.future_date", "activity.calendar.too_old"],
|
||||
ai: [
|
||||
"ai.budget.exceeded",
|
||||
"ai.provider.none",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* OpenAPI route table — the activity calendar and activity goals (D1036).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The schemas
|
||||
* are the runtime ones from `@/lib/validations/activity-*`.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import {
|
||||
activityCalendarQuerySchema,
|
||||
activityCalendarResponseSchema,
|
||||
activityGoalsResponseSchema,
|
||||
} from "@/lib/validations/activity-calendar";
|
||||
import { activityGoalsSchema } from "@/lib/validations/activity-goals";
|
||||
|
||||
import {
|
||||
dataEnvelope,
|
||||
MODULE_DISABLED_DESCRIPTION,
|
||||
recordRefusal,
|
||||
recordWriteRateLimitResponse,
|
||||
stdResponses,
|
||||
} from "./shared";
|
||||
|
||||
const activityGoalsPatch = activityGoalsSchema.meta({
|
||||
id: "ActivityGoalsPatch",
|
||||
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.",
|
||||
});
|
||||
|
||||
export const activityPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/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.",
|
||||
requestParams: { query: activityCalendarQuerySchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The calendar.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
activityCalendarResponseSchema,
|
||||
"ActivityCalendarEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...recordRefusal(MODULE_DISABLED_DESCRIPTION),
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: dataEnvelope(
|
||||
activityGoalsResponseSchema,
|
||||
"ActivityGoalsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
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: activityGoalsPatch } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The goals after the change.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
activityGoalsResponseSchema,
|
||||
"ActivityGoalsPatchEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
...recordWriteRateLimitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
recordEpochParameter,
|
||||
recordScopeParameter,
|
||||
} from "./account-sharing";
|
||||
import { activityPaths } from "./activity";
|
||||
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
|
||||
import { aiUsagePaths } from "./ai-usage";
|
||||
import { dawarichPaths } from "./dawarich";
|
||||
@@ -105,6 +106,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...syncPaths,
|
||||
...measurementPaths,
|
||||
...workoutPaths,
|
||||
...activityPaths,
|
||||
...devicePaths,
|
||||
...medicationPaths,
|
||||
...profilePaths,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Query keys — the activity calendar and activity goals (D1036).
|
||||
* Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const activityKeys = {
|
||||
/** Root of every activity-calendar cell; a goal save invalidates it. */
|
||||
activityCalendarAll: () => ["activity-calendar"] as const,
|
||||
activityCalendar: (period: "week" | "month", date: string | null) =>
|
||||
["activity-calendar", period, date ?? "today"] as const,
|
||||
activityGoals: () => ["activity-goals"] as const,
|
||||
};
|
||||
@@ -47,6 +47,7 @@ import { searchKeys } from "./search";
|
||||
import { sharingKeys } from "./sharing";
|
||||
import { vaccinationKeys } from "./vaccinations";
|
||||
import { workoutKeys } from "./workouts";
|
||||
import { activityKeys } from "./activity";
|
||||
|
||||
export const queryKeys = {
|
||||
...authKeys,
|
||||
@@ -60,6 +61,7 @@ export const queryKeys = {
|
||||
...adminKeys,
|
||||
...integrationKeys,
|
||||
...workoutKeys,
|
||||
...activityKeys,
|
||||
...settingsKeys,
|
||||
...cycleKeys,
|
||||
...measurementReminderKeys,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Query of `GET /api/activity/calendar` (D1036).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const activityCalendarQuerySchema = z.object({
|
||||
period: z
|
||||
.enum(["week", "month"])
|
||||
.default("month")
|
||||
.describe("Calendar span: one week (Monday to Sunday) or one month."),
|
||||
date: z.iso
|
||||
.date()
|
||||
.optional()
|
||||
.describe(
|
||||
"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.",
|
||||
),
|
||||
});
|
||||
|
||||
export type ActivityCalendarQuery = z.infer<typeof activityCalendarQuerySchema>;
|
||||
|
||||
const dateKey = z.iso.date();
|
||||
|
||||
const dayLevelEnum = z
|
||||
.enum(["none", "unrated", "low", "below", "usual", "above", "high"])
|
||||
.describe(
|
||||
"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.",
|
||||
);
|
||||
|
||||
const baselineStatsSchema = z
|
||||
.object({
|
||||
median: z.number(),
|
||||
p10: z.number(),
|
||||
p25: z.number(),
|
||||
p75: z.number(),
|
||||
p90: z.number(),
|
||||
days: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Days with a value the spread was computed from."),
|
||||
})
|
||||
.nullable()
|
||||
.meta({ id: "ActivityBaselineStats" });
|
||||
|
||||
const periodComparisonSchema = z
|
||||
.object({
|
||||
value: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The period so far: a total (steps, active minutes, workouts) or a mean (load).",
|
||||
),
|
||||
daysWithData: z.number().int(),
|
||||
usual: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"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.",
|
||||
),
|
||||
usualToDate: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The usual figure for the days elapsed so far; equals `usual` once the period is over.",
|
||||
),
|
||||
referencePeriods: z.number().int(),
|
||||
})
|
||||
.meta({ id: "ActivityPeriodComparison" });
|
||||
|
||||
const goalProgressSchema = z
|
||||
.object({
|
||||
key: z.enum([
|
||||
"workoutsPerWeek",
|
||||
"activeMinutesPerWeek",
|
||||
"stepsPerWeek",
|
||||
"stepsPerMonth",
|
||||
]),
|
||||
target: z.number().int(),
|
||||
value: z.number(),
|
||||
met: z.boolean(),
|
||||
})
|
||||
.meta({ id: "ActivityGoalProgress" });
|
||||
|
||||
const goalsSchema = z
|
||||
.object({
|
||||
workoutsPerWeek: z.number().int().nullable(),
|
||||
activeMinutesPerWeek: z.number().int().nullable(),
|
||||
stepsPerWeek: z.number().int().nullable(),
|
||||
stepsPerMonth: z.number().int().nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "ActivityGoals",
|
||||
description:
|
||||
"The person's own goals; `null` = not set. There is no default goal.",
|
||||
});
|
||||
|
||||
export const activityGoalsResponseSchema = goalsSchema;
|
||||
|
||||
export const activityCalendarResponseSchema = z
|
||||
.object({
|
||||
period: z.enum(["week", "month"]),
|
||||
start: dateKey,
|
||||
end: dateKey,
|
||||
gridStart: dateKey.describe(
|
||||
"First day drawn (the Monday on or before `start`).",
|
||||
),
|
||||
gridEnd: dateKey.describe("Last day drawn (the Sunday on or after `end`)."),
|
||||
today: dateKey,
|
||||
timezone: z.string().describe("The IANA zone the days are cut in."),
|
||||
loadType: z
|
||||
.enum(["DAY_STRAIN", "CARDIO_LOAD", "STRAIN_SCORE"])
|
||||
.nullable()
|
||||
.describe(
|
||||
"Which training-load score `load` carries; null when the account has none.",
|
||||
),
|
||||
days: z.array(
|
||||
z.object({
|
||||
date: dateKey,
|
||||
inPeriod: z.boolean(),
|
||||
future: z.boolean(),
|
||||
steps: z.number().nullable(),
|
||||
activeMinutes: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The larger of the day's exercise minutes and workout minutes (never their sum).",
|
||||
),
|
||||
workoutCount: z.number().int(),
|
||||
workoutsBySport: z.record(z.string(), z.number().int()),
|
||||
load: z.number().nullable(),
|
||||
level: z.object({
|
||||
steps: dayLevelEnum,
|
||||
activeMinutes: dayLevelEnum,
|
||||
load: dayLevelEnum,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
weeks: z.array(
|
||||
z.object({
|
||||
start: dateKey,
|
||||
end: dateKey,
|
||||
steps: z.number().nullable(),
|
||||
activeMinutes: z.number().nullable(),
|
||||
workouts: z.number().int(),
|
||||
goals: z
|
||||
.array(goalProgressSchema)
|
||||
.describe("The weekly goals for a week that has begun."),
|
||||
}),
|
||||
),
|
||||
baseline: z.object({
|
||||
steps: baselineStatsSchema,
|
||||
activeMinutes: baselineStatsSchema,
|
||||
load: baselineStatsSchema,
|
||||
}),
|
||||
summary: z.object({
|
||||
steps: periodComparisonSchema,
|
||||
activeMinutes: periodComparisonSchema,
|
||||
workouts: periodComparisonSchema,
|
||||
load: periodComparisonSchema,
|
||||
}),
|
||||
sports: z.array(
|
||||
z.object({ sportType: z.string(), count: z.number().int() }),
|
||||
),
|
||||
goals: goalsSchema,
|
||||
goalProgress: z.array(goalProgressSchema),
|
||||
available: z.object({
|
||||
steps: z.boolean(),
|
||||
activeMinutes: z.boolean(),
|
||||
workouts: z.boolean(),
|
||||
load: z.boolean(),
|
||||
}),
|
||||
previous: dateKey.describe("A day in the previous period."),
|
||||
next: dateKey
|
||||
.nullable()
|
||||
.describe(
|
||||
"A day in the next period, or null when that lies after today.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "ActivityCalendar" });
|
||||
|
||||
export type ActivityCalendarResponse = z.infer<
|
||||
typeof activityCalendarResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Activity goals a person sets for themselves (D1036).
|
||||
*
|
||||
* Stored in `User.activityGoalsJson`. Every goal is optional and a `null`
|
||||
* clears it; an account that never set one stores nothing and reads as "no
|
||||
* goals". The calendar reports progress against the goals that are set and
|
||||
* says nothing about the ones that are not: there is no default goal and no
|
||||
* population norm here, only the person's own number.
|
||||
*
|
||||
* The keys and bounds live in `src/lib/activity/goal-bounds.ts` so the
|
||||
* browser can read them without the schema.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
ACTIVITY_GOAL_BOUNDS,
|
||||
ACTIVITY_GOAL_KEYS,
|
||||
type ActivityGoalKey,
|
||||
} from "@/lib/activity/goal-bounds";
|
||||
|
||||
export { ACTIVITY_GOAL_BOUNDS, ACTIVITY_GOAL_KEYS, type ActivityGoalKey };
|
||||
|
||||
function goalField(key: ActivityGoalKey) {
|
||||
const { min, max } = ACTIVITY_GOAL_BOUNDS[key];
|
||||
return z.number().int().min(min).max(max).nullable().optional();
|
||||
}
|
||||
|
||||
/** The stored shape and the PATCH body: every key optional, `null` clears. */
|
||||
export const activityGoalsSchema = z
|
||||
.object({
|
||||
workoutsPerWeek: goalField("workoutsPerWeek").describe(
|
||||
"Workouts per week (Monday to Sunday). `null` clears the goal.",
|
||||
),
|
||||
activeMinutesPerWeek: goalField("activeMinutesPerWeek").describe(
|
||||
"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.",
|
||||
),
|
||||
stepsPerWeek: goalField("stepsPerWeek").describe(
|
||||
"Steps per week. `null` clears the goal.",
|
||||
),
|
||||
stepsPerMonth: goalField("stepsPerMonth").describe(
|
||||
"Steps per calendar month. `null` clears the goal.",
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ActivityGoalsInput = z.infer<typeof activityGoalsSchema>;
|
||||
|
||||
/** The resolved goals: every key present, `null` = not set. */
|
||||
export type ActivityGoals = Record<ActivityGoalKey, number | null>;
|
||||
|
||||
export const NO_ACTIVITY_GOALS: ActivityGoals = {
|
||||
workoutsPerWeek: null,
|
||||
activeMinutesPerWeek: null,
|
||||
stepsPerWeek: null,
|
||||
stepsPerMonth: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read a stored value. Anything that does not parse (a hand-edited row, a
|
||||
* shape from a future release) reads key by key: a valid goal survives, an
|
||||
* invalid one reads as not set. Never throws.
|
||||
*/
|
||||
export function parseActivityGoals(raw: unknown): ActivityGoals {
|
||||
const out: ActivityGoals = { ...NO_ACTIVITY_GOALS };
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return out;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
for (const key of ACTIVITY_GOAL_KEYS) {
|
||||
const parsed = goalField(key).safeParse(record[key]);
|
||||
if (parsed.success && typeof parsed.data === "number") {
|
||||
out[key] = parsed.data;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a validated PATCH to the stored goals: a key the body names replaces
|
||||
* the stored value (`null` clears it), a key it leaves out stays as it was.
|
||||
* Built key by key, never by spreading the body.
|
||||
*/
|
||||
export function mergeActivityGoals(
|
||||
current: ActivityGoals,
|
||||
patch: ActivityGoalsInput,
|
||||
): ActivityGoals {
|
||||
const next: ActivityGoals = { ...current };
|
||||
for (const key of ACTIVITY_GOAL_KEYS) {
|
||||
const value = patch[key];
|
||||
if (value !== undefined) next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** True when no goal is set, so the column can go back to null. */
|
||||
export function hasNoActivityGoals(goals: ActivityGoals): boolean {
|
||||
return ACTIVITY_GOAL_KEYS.every((key) => goals[key] === null);
|
||||
}
|
||||
@@ -2156,6 +2156,7 @@ const accountSettingsBackupSchema = z
|
||||
documentsLayoutJson: z.unknown().optional(),
|
||||
moduleListLayoutJson: z.unknown().optional(),
|
||||
previewFeaturesJson: z.unknown().optional(),
|
||||
activityGoalsJson: z.unknown().optional(),
|
||||
reportSelectionJson: z.unknown().optional(),
|
||||
globalExcludedInjectionSites: z.array(z.string()).optional(),
|
||||
healthKitConfigJson: z.unknown().optional(),
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* The activity calendar's reader against a real Postgres (D1036).
|
||||
*
|
||||
* - steps resolve to the source the person's ladder picks per day, so a day
|
||||
* two devices counted shows one device's total, never the sum;
|
||||
* - the day is the person's local day: a reading at 23:30 Berlin time lands
|
||||
* on that day, not on the UTC day after;
|
||||
* - a workout two devices recorded counts once, its minutes once;
|
||||
* - the window is exact: nothing before `fromKey` or after `toKey`.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { readActivityDays } from "@/lib/activity/calendar-read";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const TZ = "Europe/Berlin";
|
||||
const USER = "activity-reader";
|
||||
const DAY = 86_400_000;
|
||||
const NOW = new Date();
|
||||
|
||||
function daysAgo(n: number, hourUtc = 10): Date {
|
||||
const d = new Date(NOW.getTime() - n * DAY);
|
||||
d.setUTCHours(hourUtc, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER,
|
||||
username: USER,
|
||||
timezone: TZ,
|
||||
sourcePriorityJson: {
|
||||
metricPriority: { steps: ["FITBIT", "APPLE_HEALTH"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
invalidateUserTimezone(USER);
|
||||
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
// Day 3: both devices; the ladder picks Fitbit's 8 000.
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 9500,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(3),
|
||||
},
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 8000,
|
||||
unit: "steps",
|
||||
source: "FITBIT",
|
||||
measuredAt: daysAgo(3),
|
||||
},
|
||||
// Day 2: Apple only, two slices that add up.
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 3000,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(2, 8),
|
||||
},
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 4000,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(2, 15),
|
||||
},
|
||||
// 21:30 UTC is 23:30 in Berlin (summer) or 22:30 (winter): still day 5 locally.
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1200,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(5, 21),
|
||||
},
|
||||
{
|
||||
userId: USER,
|
||||
type: "EXERCISE_MINUTES",
|
||||
value: 25,
|
||||
unit: "min",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(3, 9),
|
||||
},
|
||||
{
|
||||
userId: USER,
|
||||
type: "EXERCISE_MINUTES",
|
||||
value: 15,
|
||||
unit: "min",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(3, 17),
|
||||
},
|
||||
// Outside the window asked for below.
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 777,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(40),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const start = daysAgo(3, 7);
|
||||
await prisma.workout.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: USER,
|
||||
sportType: "running",
|
||||
startedAt: start,
|
||||
endedAt: new Date(start.getTime() + 40 * 60_000),
|
||||
durationSec: 2400,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "hk-1",
|
||||
},
|
||||
{
|
||||
userId: USER,
|
||||
sportType: "running",
|
||||
startedAt: new Date(start.getTime() + 30_000),
|
||||
endedAt: new Date(start.getTime() + 40 * 60_000),
|
||||
durationSec: 2370,
|
||||
source: "FITBIT",
|
||||
externalId: "fb-1",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("readActivityDays", () => {
|
||||
it("follows the ladder, the local day and the workout collapse", async () => {
|
||||
const fromKey = userDayKey(daysAgo(10), TZ);
|
||||
const toKey = userDayKey(NOW, TZ);
|
||||
const { days } = await readActivityDays({
|
||||
userId: USER,
|
||||
tz: TZ,
|
||||
fromKey,
|
||||
toKey,
|
||||
now: NOW,
|
||||
includeWorkouts: true,
|
||||
});
|
||||
const by = new Map(days.map((d) => [d.date, d]));
|
||||
|
||||
const d3 = by.get(userDayKey(daysAgo(3), TZ));
|
||||
expect(d3?.steps).toBe(8000);
|
||||
expect(d3?.exerciseMinutes).toBe(40);
|
||||
expect(d3?.workoutCount).toBe(1);
|
||||
expect(d3?.workoutsBySport).toEqual({ running: 1 });
|
||||
expect(d3?.workoutMinutes).toBe(40);
|
||||
|
||||
expect(by.get(userDayKey(daysAgo(2), TZ))?.steps).toBe(7000);
|
||||
expect(by.get(userDayKey(daysAgo(5, 21), TZ))?.steps).toBe(1200);
|
||||
expect(days.every((d) => d.date >= fromKey && d.date <= toKey)).toBe(true);
|
||||
expect(days.some((d) => d.steps === 777)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user