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

@@ -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(