From 51374274aa33f9f1d1a72cdcf680f4ca39b736b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:40:39 +0200 Subject: [PATCH] List only the sources an account uses on the source-priority page The read and save of /api/auth/me/source-priority now carry inUse: the ranked sources the account has connected or holds at least one measurement from, one index probe per source. The settings page lists only those, hides a metric nothing delivers, and opens one metric group at a time, the first by default, with the winning source named on each collapsed line. The ladders stay whole: moving a source swaps it with its nearest listed neighbour, so a hidden source keeps its slot. --- docs/api/openapi.yaml | 1894 +++++------------ messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- .../source-priority/__tests__/route.test.ts | 15 + src/app/api/auth/me/source-priority/route.ts | 22 +- .../__tests__/sources-section-in-use.test.tsx | 72 + src/components/settings/sources-section.tsx | 239 ++- .../__tests__/sources-in-use.test.ts | 70 + src/lib/integrations/sources-in-use.ts | 78 + src/lib/openapi/routes/profile.ts | 20 +- 15 files changed, 956 insertions(+), 1475 deletions(-) create mode 100644 src/components/settings/__tests__/sources-section-in-use.test.tsx create mode 100644 src/lib/integrations/__tests__/sources-in-use.test.ts create mode 100644 src/lib/integrations/sources-in-use.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e9dfaa516..aacee6ab9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -51244,922 +51244,7 @@ components: type: object properties: data: - type: object - properties: - steps: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - activeEnergy: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - walkingRunningDistance: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - flightsClimbed: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - sleep: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - weight: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - bloodPressure: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - pulse: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - bodyFat: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - bodyTemperature: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - spo2: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - hrv: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - restingHeartRate: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - vo2Max: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - skinTemperature: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - respiratoryRate: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - recovery: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - stress: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - sleepDebt: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - hrvRmssd: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - metricPriority: - type: object - properties: - steps: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - activeEnergy: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - walkingRunningDistance: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - flightsClimbed: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - sleep: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - weight: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - bloodPressure: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - pulse: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - bodyFat: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - bodyTemperature: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - spo2: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - hrv: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - restingHeartRate: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - vo2Max: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - skinTemperature: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - respiratoryRate: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - recovery: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - stress: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - sleepDebt: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - hrvRmssd: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - additionalProperties: false - deviceTypePriority: - type: object - properties: - default: - maxItems: 8 - type: array - items: - type: string - enum: - - watch - - band - - ring - - phone - - scale - - other - - unknown - additionalProperties: - maxItems: 8 - type: array - items: - type: string - enum: - - watch - - band - - ring - - phone - - scale - - other - - unknown - additionalProperties: false + $ref: "#/components/schemas/ResolvedSourcePriority" error: type: "null" meta: @@ -52172,10 +51257,450 @@ components: - data - error additionalProperties: false - PutSourcePriorityResponse: + ResolvedSourcePriority: type: object properties: - data: + steps: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + activeEnergy: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + walkingRunningDistance: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + flightsClimbed: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + sleep: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + weight: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + bloodPressure: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + pulse: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + bodyFat: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + bodyTemperature: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + spo2: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + hrv: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + restingHeartRate: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + vo2Max: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + skinTemperature: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + respiratoryRate: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + recovery: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + stress: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + sleepDebt: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + hrvRmssd: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + metricPriority: type: object properties: steps: @@ -52618,480 +52143,67 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT - metricPriority: - type: object - properties: - steps: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - activeEnergy: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - walkingRunningDistance: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - flightsClimbed: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - sleep: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - weight: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - bloodPressure: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - pulse: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - bodyFat: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - bodyTemperature: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - spo2: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - hrv: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - restingHeartRate: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - vo2Max: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - skinTemperature: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - respiratoryRate: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - recovery: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - stress: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - sleepDebt: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - hrvRmssd: - maxItems: 16 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - - HEALTH_CONNECT - additionalProperties: false - deviceTypePriority: - type: object - properties: - default: - maxItems: 8 - type: array - items: - type: string - enum: - - watch - - band - - ring - - phone - - scale - - other - - unknown - additionalProperties: - maxItems: 8 - type: array - items: - type: string - enum: - - watch - - band - - ring - - phone - - scale - - other - - unknown additionalProperties: false + deviceTypePriority: + type: object + properties: + default: + maxItems: 8 + type: array + items: + type: string + enum: + - watch + - band + - ring + - phone + - scale + - other + - unknown + additionalProperties: + maxItems: 8 + type: array + items: + type: string + enum: + - watch + - band + - ring + - phone + - scale + - other + - unknown + inUse: + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + description: Ranked sources this account has connected or holds at least one measurement from, in ladder-catalogue + order. Read-only; an editor may list only these while the ladders keep every source. + required: + - inUse + additionalProperties: false + PutSourcePriorityResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ResolvedSourcePriority" error: type: "null" meta: diff --git a/messages/de.json b/messages/de.json index 1b78ce9d0..905b13f71 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5460,7 +5460,8 @@ "moduleHintLink": "Module verwalten", "detail": "Nur lesend — die Quell-App besitzt die Werte." }, - "helpDetail": "Die übrigen bleiben für die Audit-Spur gespeichert." + "helpDetail": "Die übrigen bleiben für die Audit-Spur gespeichert. Aufgeführt sind nur Quellen, die du verbunden hast oder von denen Daten vorliegen.", + "noneInUse": "Noch keine Quelle zum Sortieren. Verbinde eine unter Integrationen oder trage einen Wert ein." }, "ai": { "title": "KI-Anbieter", diff --git a/messages/en.json b/messages/en.json index 3be86c0aa..158470039 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5460,7 +5460,8 @@ "moduleHintLink": "Manage modules", "detail": "Read-only — the source app owns the numbers." }, - "helpDetail": "The others stay stored for the audit trail." + "helpDetail": "The others stay stored for the audit trail. Only sources you have connected or hold data from are listed.", + "noneInUse": "No source to rank yet. Connect one under Integrations or record a value." }, "ai": { "title": "AI provider", diff --git a/messages/es.json b/messages/es.json index 1cf707450..c1041cb78 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5460,7 +5460,8 @@ "moduleHintLink": "Gestionar módulos", "detail": "Solo lectura: la app de origen es la dueña de los valores." }, - "helpDetail": "Las demás se conservan para la traza de auditoría." + "helpDetail": "Las demás se conservan para la traza de auditoría. Solo aparecen las fuentes que has conectado o de las que hay datos.", + "noneInUse": "Aún no hay fuentes que ordenar. Conecta una en Integraciones o registra un valor." }, "ai": { "title": "Proveedor de IA", diff --git a/messages/fr.json b/messages/fr.json index 45cd54c60..c9d7eb79f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5460,7 +5460,8 @@ "moduleHintLink": "Gérer les modules", "detail": "Lecture seule : l'app source reste propriétaire des valeurs." }, - "helpDetail": "Les autres restent enregistrées pour la piste d’audit." + "helpDetail": "Les autres restent enregistrées pour la piste d’audit. Seules les sources connectées ou ayant fourni des données sont listées.", + "noneInUse": "Aucune source à classer pour l'instant. Connectez-en une dans Intégrations ou saisissez une valeur." }, "ai": { "title": "Fournisseur d'IA", diff --git a/messages/it.json b/messages/it.json index a8b186614..d7d68ce9d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5460,7 +5460,8 @@ "moduleHintLink": "Gestisci moduli", "detail": "Sola lettura: i valori appartengono all'app di origine." }, - "helpDetail": "Le altre restano memorizzate per la traccia di controllo." + "helpDetail": "Le altre restano memorizzate per la traccia di controllo. Sono elencate solo le fonti collegate o con dati.", + "noneInUse": "Ancora nessuna fonte da ordinare. Collegane una in Integrazioni o registra un valore." }, "ai": { "title": "Provider IA", diff --git a/messages/ko.json b/messages/ko.json index 7a761719e..cbf9cbc05 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5460,7 +5460,8 @@ "moduleHintLink": "모듈 관리", "detail": "읽기 전용 — 숫자는 원본 앱이 관리해요." }, - "helpDetail": "나머지는 감사 기록용으로 그대로 보관해요." + "helpDetail": "나머지는 감사 기록용으로 그대로 보관해요. 연결했거나 데이터가 있는 소스만 보여요.", + "noneInUse": "아직 순위를 정할 소스가 없어요. 연동에서 연결하거나 값을 기록해 보세요." }, "ai": { "title": "AI 제공자", diff --git a/messages/pl.json b/messages/pl.json index e04ece9e0..f682b317e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5460,7 +5460,8 @@ "moduleHintLink": "Zarządzaj modułami", "detail": "Tylko do odczytu — wartości należą do aplikacji źródłowej." }, - "helpDetail": "Pozostałe pozostają zapisane na potrzeby audytu." + "helpDetail": "Pozostałe pozostają zapisane na potrzeby audytu. Widać tylko źródła, które połączono lub z których są dane.", + "noneInUse": "Nie ma jeszcze źródeł do uszeregowania. Połącz jedno w Integracjach albo wpisz wartość." }, "ai": { "title": "Dostawca AI", diff --git a/src/app/api/auth/me/source-priority/__tests__/route.test.ts b/src/app/api/auth/me/source-priority/__tests__/route.test.ts index fdc7cc849..4b4801ea5 100644 --- a/src/app/api/auth/me/source-priority/__tests__/route.test.ts +++ b/src/app/api/auth/me/source-priority/__tests__/route.test.ts @@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/integrations/sources-in-use", () => ({ + getSourcesInUse: vi.fn().mockResolvedValue(["WITHINGS", "MANUAL"]), +})); + vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); @@ -73,6 +77,17 @@ describe("GET /api/auth/me/source-priority", () => { expect(env.data).toHaveProperty("metricPriority"); expect(env.data).toHaveProperty("deviceTypePriority"); }); + + it("carries the account's sources in use beside the ladders", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); + + const res = await (GET as () => Promise)(); + const env = (await res.json()) as { data: { inUse: string[] } }; + expect(env.data.inUse).toEqual(["WITHINGS", "MANUAL"]); + }); }); describe("PUT /api/auth/me/source-priority", () => { diff --git a/src/app/api/auth/me/source-priority/route.ts b/src/app/api/auth/me/source-priority/route.ts index d5897cd1d..e37a4a528 100644 --- a/src/app/api/auth/me/source-priority/route.ts +++ b/src/app/api/auth/me/source-priority/route.ts @@ -30,6 +30,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserProfile } from "@/lib/cache/invalidate"; +import { getSourcesInUse } from "@/lib/integrations/sources-in-use"; import { parseSourcePriority, sourcePrioritySchema, @@ -41,11 +42,19 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "auth.me.source-priority.get" } }); - const row = await prisma.user.findUnique({ - where: { id: user.id }, - select: { sourcePriorityJson: true }, + const [row, inUse] = await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { sourcePriorityJson: true }, + }), + getSourcesInUse(user.id), + ]); + // `inUse` is read-only context for the editor: which ranked sources this + // account has connected or holds data from. It never feeds the ladder. + return apiSuccess({ + ...parseSourcePriority(row?.sourcePriorityJson), + inUse, }); - return apiSuccess(parseSourcePriority(row?.sourcePriorityJson)); }); export const PUT = apiHandler(async (req: Request) => { @@ -118,5 +127,8 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.source-priority.put" }, meta: { keys: Object.keys(parsed.data).length }, }); - return apiSuccess(parseSourcePriority(parsed.data)); + return apiSuccess({ + ...parseSourcePriority(parsed.data), + inUse: await getSourcesInUse(user.id), + }); }); diff --git a/src/components/settings/__tests__/sources-section-in-use.test.tsx b/src/components/settings/__tests__/sources-section-in-use.test.tsx new file mode 100644 index 000000000..e2b2dbb16 --- /dev/null +++ b/src/components/settings/__tests__/sources-section-in-use.test.tsx @@ -0,0 +1,72 @@ +/** + * Settings → Source priority lists only the sources the account has connected + * or holds data from, and opens one metric group at a time. The ladders it + * edits stay whole: a hidden source keeps its slot when a visible one moves. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { parseSourcePriority } from "@/lib/validations/source-priority"; +import { moveAmongVisible, SourcesSection } from "../sources-section"; + +function render(inUse: string[] | undefined): string { + const client = new QueryClient({ + defaultOptions: { queries: { retry: 0, staleTime: Infinity } }, + }); + client.setQueryData(queryKeys.sourcePriority(), { + ...parseSourcePriority(null), + ...(inUse ? { inUse } : {}), + }); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("Settings → Source priority, sources in use", () => { + it("lists only metrics and sources the account uses", () => { + const html = render(["WITHINGS"]); + expect(html).toContain('data-testid="sources-metric-weight"'); + // WHOOP-only metric classes have nothing in use here. + expect(html).not.toContain('data-testid="sources-metric-stress"'); + expect(html).not.toContain("Apple Health"); + }); + + it("opens the first group and collapses the rest", () => { + const html = render(["WITHINGS", "MANUAL"]); + const expanded = html.match(/aria-expanded="true"/g) ?? []; + expect(expanded).toHaveLength(1); + expect(html).toContain('aria-expanded="false"'); + }); + + it("says so when no source is in use at all", () => { + expect(render([])).toContain('data-testid="sources-none-in-use"'); + }); + + it("lists every source when the server sends no in-use list", () => { + expect(render(undefined)).toContain("Apple Health"); + }); +}); + +describe("moveAmongVisible", () => { + const visible = (s: string) => s !== "B"; + + it("swaps past a hidden neighbour, keeping its slot", () => { + expect(moveAmongVisible(["A", "B", "C"], "C", -1, visible)).toEqual([ + "C", + "B", + "A", + ]); + }); + + it("refuses a move with no visible neighbour", () => { + expect(moveAmongVisible(["A", "B", "C"], "A", -1, visible)).toBeNull(); + expect(moveAmongVisible(["A", "B"], "A", 1, visible)).toBeNull(); + }); +}); diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 8e88cd081..68f5e54dd 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -69,6 +69,38 @@ import { apiGet, apiPut } from "@/lib/api/api-fetch"; interface ResolvedPriority extends Required { metricPriority: Required; deviceTypePriority: DeviceTypePriority; + /** + * The ranked sources this account has connected or holds data from. The + * editor lists only these; the ladders it edits keep every source. + */ + inUse?: string[]; +} + +type LadderSource = Required[SourcePriorityMetricKey][number]; + +/** + * Move `source` one step past its nearest VISIBLE neighbour in `list`. + * + * The editor hides sources the account does not use, so an adjacent swap on + * the full ladder would trade places with an invisible entry and look like a + * button that did nothing. Swapping with the next visible entry keeps the + * hidden ones in their slots. Returns `null` when there is no visible + * neighbour in that direction. + */ +export function moveAmongVisible( + list: readonly T[], + source: T, + delta: -1 | 1, + isVisible: (entry: T) => boolean, +): T[] | null { + const from = list.indexOf(source); + if (from < 0) return null; + let to = from + delta; + while (to >= 0 && to < list.length && !isVisible(list[to])) to += delta; + if (to < 0 || to >= list.length) return null; + const next = [...list]; + [next[from], next[to]] = [next[to], next[from]]; + return next; } const METRIC_LABEL_KEYS: Record = { @@ -207,13 +239,13 @@ export function SourcesSection() { function moveSource( metric: SourcePriorityMetricKey, - index: number, + source: LadderSource, delta: -1 | 1, ) { if (!priority) return; const current = priority.metricPriority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric]; - const list = reorderLadder(current, index, delta); + const list = moveAmongVisible(current, source, delta, isSourceVisible); if (!list) return; const nextMetric = { ...priority.metricPriority, [metric]: list }; setDraft({ @@ -262,6 +294,46 @@ export function SourcesSection() { const dirty = draft !== null && priority !== null; + // An older server sends no `inUse`; then every source stays listed. + const remoteInUse = remote?.inUse; + const inUse = useMemo( + () => (remoteInUse ? new Set(remoteInUse) : null), + [remoteInUse], + ); + function isSourceVisible(source: string): boolean { + return inUse === null || inUse.has(source); + } + + // The metrics worth showing: those with at least one source in use. A + // metric nobody delivers has nothing to rank. + const visibleMetrics = useMemo(() => { + if (!priority) return [] as SourcePriorityMetricKey[]; + return SOURCE_PRIORITY_METRIC_KEYS.filter((metric) => + (priority.metricPriority[metric] ?? DEFAULT_SOURCE_PRIORITY[metric]).some( + (source) => inUse === null || inUse.has(source), + ), + ); + }, [priority, inUse]); + + // Every group starts collapsed except the first, so the page opens on one + // worked example instead of twenty open ladders. Toggled groups are + // remembered by key; the first group's default is the inverse. + const [toggledMetrics, setToggledMetrics] = useState< + ReadonlySet + >(new Set()); + function isMetricOpen(metric: SourcePriorityMetricKey): boolean { + const openByDefault = metric === visibleMetrics[0]; + return toggledMetrics.has(metric) ? !openByDefault : openByDefault; + } + function toggleMetric(metric: SourcePriorityMetricKey) { + setToggledMetrics((prev) => { + const next = new Set(prev); + if (next.has(metric)) next.delete(metric); + else next.add(metric); + return next; + }); + } + // The per-metric expander shows every metric whose ladder has been // overridden, plus a hint when none has. Counter lives next to the // expander label so the section "asks" for attention proportional @@ -303,69 +375,100 @@ export function SourcesSection() { {isLoading || !priority ? ( + ) : visibleMetrics.length === 0 ? ( + ) : (
- {SOURCE_PRIORITY_METRIC_KEYS.map((metric) => { - const list = + {visibleMetrics.map((metric) => { + const list = ( priority.metricPriority[metric] ?? - DEFAULT_SOURCE_PRIORITY[metric]; + DEFAULT_SOURCE_PRIORITY[metric] + ).filter(isSourceVisible); + const open = isMetricOpen(metric); + const panelId = `sources-metric-${metric}`; return (
-
- +
-
    - {list.map((source, index) => ( -
  • - - {index + 1}. - - - {t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])} - - {/* Up/down sit side by side at every width: two - 44 px buttons leave a 390 px row ~170 px for the - source name. Stacking them on phones (v1.4.27 - MB2) doubled every row and pushed this page past - twelve thousand pixels. */} -
    - - -
    -
  • - ))} -
+ {/* Collapsed, the group still says who wins. */} + {!open && list[0] ? ( + + {t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[list[0]])} + + ) : null} + + {open ? ( +
    + {list.map((source, index) => ( +
  • + + {index + 1}. + + + {t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])} + + {/* Up/down sit side by side at every width: two + 44 px buttons leave a 390 px row ~170 px for the + source name. */} +
    + + +
    +
  • + ))} +
+ ) : null}
); })} @@ -607,9 +710,9 @@ export function SourcesSection() { /** * Skeleton placeholder rendered while `/api/auth/me/source-priority` - * is in flight. Reserves one row per `SOURCE_PRIORITY_METRIC_KEYS` - * entry at roughly the loaded height so the page does not jump when - * the fetched ladder list swaps in. The pulsing animation honours + * is in flight. Mirrors the loaded shape: the first group open with a + * couple of rows, the rest collapsed to their header line, so the page does + * not jump when the ladders swap in. The pulsing animation honours * `prefers-reduced-motion` via Tailwind's `motion-reduce:animate-none`. */ function SourcesSkeletonList() { @@ -619,17 +722,13 @@ function SourcesSkeletonList() { data-testid="sources-skeleton" aria-hidden="true" > - {SOURCE_PRIORITY_METRIC_KEYS.map((metric) => ( -
- -
- - -
-
+
+ + + +
+ {[0, 1, 2, 3].map((i) => ( + ))}
); diff --git a/src/lib/integrations/__tests__/sources-in-use.test.ts b/src/lib/integrations/__tests__/sources-in-use.test.ts new file mode 100644 index 000000000..92f72bb06 --- /dev/null +++ b/src/lib/integrations/__tests__/sources-in-use.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findFirst: vi.fn() }, + user: { findUnique: vi.fn() }, + withingsConnection: { findUnique: vi.fn() }, + whoopConnection: { findUnique: vi.fn() }, + fitbitConnection: { findUnique: vi.fn() }, + googleHealthConnection: { findUnique: vi.fn() }, + }, +})); + +import { prisma } from "@/lib/db"; +import { + getSourcesInUse, + RANKABLE_SOURCES, +} from "@/lib/integrations/sources-in-use"; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue(null); + vi.mocked(prisma.whoopConnection.findUnique).mockResolvedValue(null); + vi.mocked(prisma.fitbitConnection.findUnique).mockResolvedValue(null); + vi.mocked(prisma.googleHealthConnection.findUnique).mockResolvedValue(null); +}); + +describe("getSourcesInUse", () => { + it("lists a source the account holds data from", async () => { + vi.mocked(prisma.measurement.findFirst).mockImplementation(((args: { + where: { source: string }; + }) => + Promise.resolve( + args.where.source === "MANUAL" ? { id: "m1" } : null, + )) as never); + + expect(await getSourcesInUse("u1")).toEqual(["MANUAL"]); + }); + + it("lists a connected integration that has not delivered yet", async () => { + vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null); + vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({ + userId: "u1", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + polarAccessTokenEncrypted: "x", + ouraAccessTokenEncrypted: null, + stravaAccessTokenEncrypted: null, + } as never); + + const inUse = await getSourcesInUse("u1"); + expect(inUse).toContain("WITHINGS"); + expect(inUse).toContain("POLAR"); + expect(inUse).not.toContain("OURA"); + }); + + it("lists nothing for an account with no data and no connection", async () => { + vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null); + expect(await getSourcesInUse("u1")).toEqual([]); + }); + + it("probes every rankable source exactly once", async () => { + vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null); + await getSourcesInUse("u1"); + expect(prisma.measurement.findFirst).toHaveBeenCalledTimes( + RANKABLE_SOURCES.length, + ); + }); +}); diff --git a/src/lib/integrations/sources-in-use.ts b/src/lib/integrations/sources-in-use.ts new file mode 100644 index 000000000..3406b7d19 --- /dev/null +++ b/src/lib/integrations/sources-in-use.ts @@ -0,0 +1,78 @@ +import type { MeasurementSource } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { DEFAULT_SOURCE_PRIORITY } from "@/lib/validations/source-priority"; + +/** + * Every source a source-priority ladder can rank, in a stable order. + * + * Derived from the default ladders rather than listed by hand, so a source + * added to a ladder is probed without a second edit here. + */ +export const RANKABLE_SOURCES: readonly MeasurementSource[] = Array.from( + new Set(Object.values(DEFAULT_SOURCE_PRIORITY).flat()), +) as MeasurementSource[]; + +/** + * The rankable sources this account actually has: a source it holds at least + * one measurement from, or an integration it has connected that has not + * delivered yet. + * + * The Settings ladder lists only these. Ranking a source the person has never + * used decides nothing, and ten of them per metric buried the two that do. + * The ladders themselves stay whole: hidden sources keep their slot, so a + * source that starts delivering later lands where the ladder already put it. + * + * Each data probe is a `LIMIT 1` on the `(userId, source, measuredAt)` index, + * so the cost is one index lookup per rankable source, not a scan. + */ +export async function getSourcesInUse( + userId: string, +): Promise { + const [withData, user, withings, whoop, fitbit, googleHealth] = + await Promise.all([ + Promise.all( + RANKABLE_SOURCES.map((source) => + prisma.measurement + .findFirst({ where: { userId, source }, select: { id: true } }) + .then((row) => (row ? source : null)), + ), + ), + prisma.user.findUnique({ + where: { id: userId }, + select: { + polarAccessTokenEncrypted: true, + ouraAccessTokenEncrypted: true, + stravaAccessTokenEncrypted: true, + }, + }), + prisma.withingsConnection.findUnique({ + where: { userId }, + select: { userId: true }, + }), + prisma.whoopConnection.findUnique({ + where: { userId }, + select: { userId: true }, + }), + prisma.fitbitConnection.findUnique({ + where: { userId }, + select: { userId: true }, + }), + prisma.googleHealthConnection.findUnique({ + where: { userId }, + select: { userId: true }, + }), + ]); + + const inUse = new Set( + withData.filter((s): s is MeasurementSource => s !== null), + ); + if (withings) inUse.add("WITHINGS"); + if (whoop) inUse.add("WHOOP"); + if (fitbit) inUse.add("FITBIT"); + if (googleHealth) inUse.add("GOOGLE_HEALTH"); + if (user?.polarAccessTokenEncrypted) inUse.add("POLAR"); + if (user?.ouraAccessTokenEncrypted) inUse.add("OURA"); + if (user?.stravaAccessTokenEncrypted) inUse.add("STRAVA"); + + return RANKABLE_SOURCES.filter((s) => inUse.has(s)); +} diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index f0eff0b6e..12d7da622 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -9,6 +9,7 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { notificationPrefsSchema } from "@/lib/validations/notification-prefs"; import { sourcePrioritySchema } from "@/lib/validations/source-priority"; +import { measurementSourceEnum } from "@/lib/validations/measurement"; import { modulePrefsPatchSchema } from "@/lib/validations/modules"; import { MODULE_KEYS } from "@/lib/modules/registry"; import { @@ -204,6 +205,21 @@ export const aiAccountBlock = z "Which AI capabilities the record this session is inside has, and why not when it has none. Resolved on the server for the ACTIVE RECORD and masked to the sections the active grant opens, exactly like `modules` and `moduleAccess`; with no switch (every native request) it describes the caller's own record.", }); +/** + * The source-priority read and save both echo the resolved ladders plus + * `inUse`: the ranked sources the account has connected or holds data from. + * Context for an editor, never part of the stored shape (the PUT body stays + * `SourcePriority`). + */ +const sourcePriorityResolved = sourcePrioritySchema + .extend({ + inUse: z.array(measurementSourceEnum).meta({ + description: + "Ranked sources this account has connected or holds at least one measurement from, in ladder-catalogue order. Read-only; an editor may list only these while the ladders keep every source.", + }), + }) + .meta({ id: "ResolvedSourcePriority" }); + const moduleMapEnvelopeInner = z .object({ modules: moduleMapResolved, updatedAt: updatedAtTokenField }) .meta({ id: "ModulesResponse" }); @@ -1425,7 +1441,7 @@ export const profilePaths: NonNullable = { content: { "application/json": { schema: dataEnvelope( - sourcePrioritySchema, + sourcePriorityResolved, "GetSourcePriorityResponse", ), }, @@ -1449,7 +1465,7 @@ export const profilePaths: NonNullable = { content: { "application/json": { schema: dataEnvelope( - sourcePrioritySchema, + sourcePriorityResolved, "PutSourcePriorityResponse", ), },