feat(integrations): list imported cycle days in the details

The details of the Garmin and Oura cards listed measurement types and
workouts only, so cycle days a wearable imported appeared nowhere, and a
bug report could not say whether any had arrived. The status envelope now
carries a `CYCLE_DAYS` entry per cycle-importing source with the newest
day and the count, and the freshness list renders it. It counts as a
sparse type, so a month between periods never reads as a dead pipe.

Refs #1333
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 22:03:18 +01:00
1 parent afdb8163ce
commit 27a1cd6c2f
19 files changed
+200 -28

No files matched your search

+3
View File
@@ -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
+20 -8
View File
@@ -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
+3 -1
View File
@@ -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."
+3 -1
View File
@@ -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."
+3 -1
View File
@@ -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."
+3 -1
View File
@@ -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."
+3 -1
View File
@@ -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."
+3 -1
View File
@@ -6904,7 +6904,9 @@
"summary": "데이터 유형 {count}개, 최신 {relative}",
"quiet": "{count}개 잠잠",
"workouts": "운동",
"medicationDoses": "약 복용"
"medicationDoses": "약 복용",
"cycleDays": "주기 기록일",
"cycleDaysCount": "주기 기록일 ({count})"
},
"integrationFallback": {
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요."
+3 -1
View File
@@ -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."
+3 -1
View File
@@ -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ń."
+3 -1
View File
@@ -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."
+3 -1
View File
@@ -6904,7 +6904,9 @@
"summary": "Типов данных: {count}, новейшие {relative}",
"quiet": "Без новых данных: {count}",
"workouts": "Тренировки",
"medicationDoses": "Дозы лекарств"
"medicationDoses": "Дозы лекарств",
"cycleDays": "Дни цикла",
"cycleDaysCount": "Дни цикла ({count})"
},
"integrationFallback": {
"description": "Настроено в этом аккаунте без собственной карточки настроек."
@@ -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 () => []) },
},
}));
@@ -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"');
@@ -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)}
</span>
</span>
<time
@@ -28,10 +28,12 @@ const groupByMock = {
const liveTypesMock = vi.hoisted(() => vi.fn());
const queryRawMock = vi.hoisted(() => vi.fn());
const workoutGroupByMock = vi.hoisted(() => vi.fn());
const cycleDayGroupByMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: queryRawMock,
workout: { groupBy: workoutGroupByMock },
cycleDayLog: { groupBy: cycleDayGroupByMock },
},
}));
vi.mock("@/lib/measurements/live-types", () => ({
@@ -77,6 +79,8 @@ beforeEach(() => {
);
workoutGroupByMock.mockReset();
workoutGroupByMock.mockResolvedValue([]);
cycleDayGroupByMock.mockReset();
cycleDayGroupByMock.mockResolvedValue([]);
});
describe("getSourceMetricFreshness", () => {
@@ -117,6 +121,47 @@ describe("getSourceMetricFreshness", () => {
).toEqual([...Object.values(INTEGRATION_MEASUREMENT_SOURCE)].sort());
});
it("lists the cycle days a wearable imported, newest day and count", async () => {
cycleDayGroupByMock.mockResolvedValue([
{ source: "GARMIN", _max: { date: "2026-10-20" }, _count: { _all: 42 } },
{ source: "OURA", _max: { date: null }, _count: { _all: 0 } },
]);
const result = await getSourceMetricFreshness("u1");
expect(result.garmin).toEqual([
{
type: "CYCLE_DAYS",
lastSeenAt: "2026-10-20T12:00:00.000Z",
count: 42,
},
]);
expect(result.oura).toBeUndefined();
// Only the live rows of the cycle-importing sources are asked about.
expect(cycleDayGroupByMock).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId: "u1",
deletedAt: null,
source: { in: ["GARMIN", "OURA"] },
},
}),
);
});
it("never calls a quiet period a dead pipe", () => {
const [entry] = classifyMetricFreshness(
[
{
type: "CYCLE_DAYS",
lastSeenAt: "2026-01-01T12:00:00.000Z",
count: 5,
},
],
"fresh",
new Date("2026-07-01T00:00:00.000Z"),
);
expect(entry).toMatchObject({ stale: false, count: 5 });
});
it("skips rows with no reading and unmapped sources", async () => {
groupByMock.mockResolvedValue([
{ source: "OURA", type: "VO2_MAX", _max: { measuredAt: null } },
+52 -4
View File
@@ -19,7 +19,8 @@
* Two sources write no `Measurement` rows for part of what they deliver:
* Strava writes only `Workout` rows, and Polar's workout leg is one of its two
* cron legs. A second grouped read over `Workout` appends a `WORKOUTS`
* pseudo-entry so those pipes are visible too.
* pseudo-entry so those pipes are visible too, and a third over `CycleDayLog`
* a `CYCLE_DAYS` entry for the wearables that import period days.
*/
import { prisma } from "@/lib/db";
import type {
@@ -29,6 +30,7 @@ import type {
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import type { IntegrationKey } from "./status";
import {
CYCLE_DAY_FRESHNESS_TYPE,
MEDICATION_DOSE_FRESHNESS_TYPE,
WORKOUT_FRESHNESS_TYPE,
type MetricFreshnessSample,
@@ -76,6 +78,14 @@ export const INTEGRATION_WORKOUT_SOURCE: Partial<
garmin: "GARMIN",
};
/** The `CycleDayLog` source of each integration that imports period days. */
export const INTEGRATION_CYCLE_SOURCE: Partial<
Record<IntegrationKey, MeasurementSource>
> = {
garmin: "GARMIN",
oura: "OURA",
};
/**
* Newest measurement per `(source, type)` for the given sources. A `(source,
* type)` pair with no rows simply has no entry, which is how honest absence
@@ -164,6 +174,33 @@ export async function getWorkoutFreshnessBySource(
return out;
}
/**
* Newest live cycle day per source, with the count, as a `CYCLE_DAYS` sample.
* A cycle day is a calendar day, dated at noon UTC like every date-only value.
*/
export async function getCycleDayFreshnessBySource(
userId: string,
sources: readonly MeasurementSource[],
): Promise<Map<MeasurementSource, MetricFreshnessSample>> {
const out = new Map<MeasurementSource, MetricFreshnessSample>();
if (sources.length === 0) return out;
const grouped = await prisma.cycleDayLog.groupBy({
by: ["source"],
where: { userId, deletedAt: null, source: { in: [...sources] } },
_max: { date: true },
_count: { _all: true },
});
for (const row of grouped) {
if (!row._max.date) continue;
out.set(row.source, {
type: CYCLE_DAY_FRESHNESS_TYPE,
lastSeenAt: `${row._max.date}T12:00:00.000Z`,
count: row._count._all,
});
}
return out;
}
/**
* Per-metric last-value timestamps for one measurement source, sorted
* alphabetically for a stable wire shape. Used by the Apple Health route, whose
@@ -256,13 +293,13 @@ export async function getEnteredHereAt(
/**
* Compute per-`(integration, type)` last-value timestamps for the sync
* integrations, keyed by `IntegrationKey`. Two grouped queries (measurements +
* workouts); a source with no rows simply has no entry.
* integrations, keyed by `IntegrationKey`. Three grouped reads (measurements,
* workouts, cycle days); a source with no rows simply has no entry.
*/
export async function getSourceMetricFreshness(
userId: string,
): Promise<Partial<Record<IntegrationKey, MetricFreshnessSample[]>>> {
const [measurements, workouts] = await Promise.all([
const [measurements, workouts, cycleDays] = await Promise.all([
getMeasurementFreshnessBySource(
userId,
Object.values(INTEGRATION_MEASUREMENT_SOURCE),
@@ -271,6 +308,10 @@ export async function getSourceMetricFreshness(
userId,
Object.values(INTEGRATION_WORKOUT_SOURCE),
),
getCycleDayFreshnessBySource(
userId,
Object.values(INTEGRATION_CYCLE_SOURCE),
),
]);
const out: Partial<Record<IntegrationKey, MetricFreshnessSample[]>> = {};
@@ -293,6 +334,13 @@ export async function getSourceMetricFreshness(
});
}
for (const [key, source] of Object.entries(INTEGRATION_CYCLE_SOURCE) as Array<
[IntegrationKey, MeasurementSource]
>) {
const sample = cycleDays.get(source);
if (sample) (out[key] ??= []).push(sample);
}
for (const key of Object.keys(out) as IntegrationKey[]) {
out[key] = sortByType(out[key]!);
}
+12 -2
View File
@@ -285,6 +285,8 @@ export const SPARSE_METRIC_TYPES: ReadonlySet<string> = new Set([
"WORKOUTS",
// As-needed and paused medications leave weeks without a dose by design.
"MEDICATION_DOSE",
// A period comes once a month, and not at all in a pregnancy.
"CYCLE_DAYS",
]);
/** The pseudo-type carrying a source's newest workout, next to its metrics. */
@@ -296,15 +298,23 @@ export const WORKOUT_FRESHNESS_TYPE = "WORKOUTS";
*/
export const MEDICATION_DOSE_FRESHNESS_TYPE = "MEDICATION_DOSE";
/**
* The pseudo-type carrying the newest cycle day a wearable imported (Garmin,
* Oura), with how many it holds.
*/
export const CYCLE_DAY_FRESHNESS_TYPE = "CYCLE_DAYS";
/** Newest recorded reading for one `(source, type)` pair, as queried. */
export interface MetricFreshnessSample {
/**
* The `MeasurementType` (e.g. "RESPIRATORY_RATE"), `WORKOUTS`, or
* `MEDICATION_DOSE` on the Apple Health list.
* The `MeasurementType` (e.g. "RESPIRATORY_RATE"), `WORKOUTS`,
* `CYCLE_DAYS`, or `MEDICATION_DOSE` on the Apple Health list.
*/
type: string;
/** ISO timestamp of the newest (non-deleted) reading for this metric. */
lastSeenAt: string;
/** How many live rows the source holds. `CYCLE_DAYS` only. */
count?: number;
}
/** A sample plus the server's staleness verdict for it. */
+10 -2
View File
@@ -243,11 +243,19 @@ const metricFreshnessEntry = z
type: z
.string()
.describe(
"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.",
"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: isoDateTime().describe(
"Newest recorded reading for this type from this source.",
"Newest recorded reading for this type from this source. On `CYCLE_DAYS`, the newest cycle day at noon UTC.",
),
count: z
.number()
.int()
.nonnegative()
.optional()
.describe(
"How many live cycle days the source holds. Present on `CYCLE_DAYS` only.",
),
stale: z
.boolean()
.describe(