diff --git a/CHANGELOG.md b/CHANGELOG.md index bd8159d1b..f5436773f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ - **Garmin in the setup questions.** The onboarding question about where your readings come from now offers Garmin, and can end on connecting it. (#1333) +- **Cycle days in the integration details.** The Garmin and Oura cards list + the newest imported cycle day and how many there are under "Data + received", next to the other data types. (#1333) - **Typing into the smoking history.** "When you stopped" only took text that was already a whole YYYY-MM month, so every keystroke was dropped and only a paste got in. The start year clamped each keystroke into its diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eaf513ca2..a83cd903e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -83834,14 +83834,20 @@ components: properties: type: type: string - description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, - since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or - skip a type they do not know rather than fail. + description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, `CYCLE_DAYS` for the period + days a wearable imported (Garmin, Oura), or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for + medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than + fail. lastSeenAt: type: string format: date-time 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])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - description: Newest recorded reading for this type from this source. + description: Newest recorded reading for this type from this source. On `CYCLE_DAYS`, the newest cycle day at noon UTC. + count: + description: How many live cycle days the source holds. Present on `CYCLE_DAYS` only. + type: integer + minimum: 0 + maximum: 9007199254740991 stale: type: boolean description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single @@ -86477,14 +86483,20 @@ components: properties: type: type: string - description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, - since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or - skip a type they do not know rather than fail. + description: The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, `CYCLE_DAYS` for the period + days a wearable imported (Garmin, Oura), or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for + medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than + fail. lastSeenAt: type: string format: date-time 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])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - description: Newest recorded reading for this type from this source. + description: Newest recorded reading for this type from this source. On `CYCLE_DAYS`, the newest cycle day at noon UTC. + count: + description: How many live cycle days the source holds. Present on `CYCLE_DAYS` only. + type: integer + minimum: 0 + maximum: 9007199254740991 stale: type: boolean description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single diff --git a/messages/de.json b/messages/de.json index addd1369c..679099eef 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6904,7 +6904,9 @@ "summary": "{count} Datentypen, neuestes {relative}", "quiet": "{count} still", "workouts": "Workouts", - "medicationDoses": "Medikamenteneinnahmen" + "medicationDoses": "Medikamenteneinnahmen", + "cycleDays": "Zyklustage", + "cycleDaysCount": "Zyklustage ({count})" }, "integrationFallback": { "description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte." diff --git a/messages/en.json b/messages/en.json index 6eecabb83..9b216d13c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6904,7 +6904,9 @@ "summary": "{count} data types, newest {relative}", "quiet": "{count} quiet", "workouts": "Workouts", - "medicationDoses": "Medication doses" + "medicationDoses": "Medication doses", + "cycleDays": "Cycle days", + "cycleDaysCount": "Cycle days ({count})" }, "integrationFallback": { "description": "Configured on this account without a settings card of its own." diff --git a/messages/es.json b/messages/es.json index 663d66f6e..11642bb05 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6904,7 +6904,9 @@ "summary": "{count} tipos de datos, más reciente {relative}", "quiet": "{count} en silencio", "workouts": "Entrenamientos", - "medicationDoses": "Tomas de medicación" + "medicationDoses": "Tomas de medicación", + "cycleDays": "Días del ciclo", + "cycleDaysCount": "Días del ciclo ({count})" }, "integrationFallback": { "description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia." diff --git a/messages/fr.json b/messages/fr.json index 50e0eb018..e1fc6d686 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6904,7 +6904,9 @@ "summary": "{count} types de données, plus récent {relative}", "quiet": "{count} silencieux", "workouts": "Séances", - "medicationDoses": "Prises de médicaments" + "medicationDoses": "Prises de médicaments", + "cycleDays": "Jours du cycle", + "cycleDaysCount": "Jours du cycle ({count})" }, "integrationFallback": { "description": "Configuré sur ce compte sans carte de réglages dédiée." diff --git a/messages/it.json b/messages/it.json index c05731311..0bc50c2c7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6904,7 +6904,9 @@ "summary": "{count} tipi di dati, più recente {relative}", "quiet": "{count} in silenzio", "workouts": "Allenamenti", - "medicationDoses": "Assunzioni di farmaci" + "medicationDoses": "Assunzioni di farmaci", + "cycleDays": "Giorni del ciclo", + "cycleDaysCount": "Giorni del ciclo ({count})" }, "integrationFallback": { "description": "Configurato su questo account ma senza una scheda di impostazioni propria." diff --git a/messages/ko.json b/messages/ko.json index 10ebcb44e..665a02dd8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6904,7 +6904,9 @@ "summary": "데이터 유형 {count}개, 최신 {relative}", "quiet": "{count}개 잠잠", "workouts": "운동", - "medicationDoses": "약 복용" + "medicationDoses": "약 복용", + "cycleDays": "주기 기록일", + "cycleDaysCount": "주기 기록일 ({count})" }, "integrationFallback": { "description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요." diff --git a/messages/nl.json b/messages/nl.json index a53d0187c..c029834f3 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6904,7 +6904,9 @@ "summary": "{count} gegevenstypen, nieuwste {relative}", "quiet": "{count} stil", "workouts": "Trainingen", - "medicationDoses": "Medicatiedoses" + "medicationDoses": "Medicatiedoses", + "cycleDays": "Cyclusdagen", + "cycleDaysCount": "Cyclusdagen ({count})" }, "integrationFallback": { "description": "Ingesteld op dit account zonder eigen instellingenkaart." diff --git a/messages/pl.json b/messages/pl.json index c9b05a0c0..1bd886c92 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6904,7 +6904,9 @@ "summary": "{count} typów danych, najnowsze {relative}", "quiet": "{count} bez danych", "workouts": "Treningi", - "medicationDoses": "Dawki leków" + "medicationDoses": "Dawki leków", + "cycleDays": "Dni cyklu", + "cycleDaysCount": "Dni cyklu ({count})" }, "integrationFallback": { "description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień." diff --git a/messages/pt-BR.json b/messages/pt-BR.json index eb89e8d03..7b673743d 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -6904,7 +6904,9 @@ "summary": "{count} tipos de dados, mais recente {relative}", "quiet": "{count} sem atividade", "workouts": "Treinos", - "medicationDoses": "Doses de medicamentos" + "medicationDoses": "Doses de medicamentos", + "cycleDays": "Dias do ciclo", + "cycleDaysCount": "Dias do ciclo ({count})" }, "integrationFallback": { "description": "Configurado nesta conta sem um cartão de configurações próprio." diff --git a/messages/ru.json b/messages/ru.json index 6563de908..8a0760e91 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -6904,7 +6904,9 @@ "summary": "Типов данных: {count}, новейшие {relative}", "quiet": "Без новых данных: {count}", "workouts": "Тренировки", - "medicationDoses": "Дозы лекарств" + "medicationDoses": "Дозы лекарств", + "cycleDays": "Дни цикла", + "cycleDaysCount": "Дни цикла ({count})" }, "integrationFallback": { "description": "Настроено в этом аккаунте без собственной карточки настроек." diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index c25c41cf8..fb5d94b5b 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -19,6 +19,7 @@ vi.mock("@/lib/db", () => ({ measurement: { findFirst: vi.fn(async () => null) }, $queryRaw: vi.fn(async () => []), workout: { groupBy: vi.fn(async () => []) }, + cycleDayLog: { groupBy: vi.fn(async () => []) }, }, })); diff --git a/src/components/settings/integrations/__tests__/garmin-card.test.tsx b/src/components/settings/integrations/__tests__/garmin-card.test.tsx index b089ffe26..e2e73db2c 100644 --- a/src/components/settings/integrations/__tests__/garmin-card.test.tsx +++ b/src/components/settings/integrations/__tests__/garmin-card.test.tsx @@ -72,6 +72,25 @@ function connected(keyExpiry: GarminKeyExpiry | null) { } describe("GarminCard on the shared card", () => { + it("lists the imported cycle days in the details", () => { + const html = render({ + connected: true, + configured: true, + health: healthFixture(), + keyExpiry: null, + metricFreshness: [ + { + type: "CYCLE_DAYS", + lastSeenAt: "2026-10-20T12:00:00.000Z", + count: 42, + stale: false, + }, + ], + }); + expect(html).toContain('data-metric="CYCLE_DAYS"'); + expect(html).toContain("Cycle days (42)"); + }); + it("shows the key form inside the shared card while not connected", () => { const html = render({ connected: false, configured: false, health: null }); expect(html).toContain('data-slot="integration-card"'); diff --git a/src/components/settings/integrations/metric-freshness-list.tsx b/src/components/settings/integrations/metric-freshness-list.tsx index 6fe93c55d..4742a46cd 100644 --- a/src/components/settings/integrations/metric-freshness-list.tsx +++ b/src/components/settings/integrations/metric-freshness-list.tsx @@ -32,6 +32,8 @@ import { cn } from "@/lib/utils"; const WORKOUT_TYPE = "WORKOUTS"; /** The pseudo-type carrying Apple Health medication doses. */ const MEDICATION_DOSE_TYPE = "MEDICATION_DOSE"; +/** The pseudo-type carrying the cycle days a wearable imported. */ +const CYCLE_DAY_TYPE = "CYCLE_DAYS"; /** * Turn an unmapped enum name into something readable. `MEASUREMENT_TYPE_LABEL_KEYS` @@ -67,7 +69,11 @@ export function MetricFreshnessList< if (!entries?.length) return null; const reference = now ?? new Date(); - const label = (type: string): string => { + const label = (type: string, count?: number): string => { + if (type === CYCLE_DAY_TYPE) + return count === undefined + ? t("settings.integrationFreshness.cycleDays") + : t("settings.integrationFreshness.cycleDaysCount", { count }); if (type === WORKOUT_TYPE) return t("settings.integrationFreshness.workouts"); if (type === MEDICATION_DOSE_TYPE) @@ -142,7 +148,7 @@ export function MetricFreshnessList< extra && !entry.stale && "text-foreground", )} > - {label(entry.type)} + {label(entry.type, entry.count)}