mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
afdb8163ce
commit
27a1cd6c2f
19 files changed
+200
-28
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -6904,7 +6904,9 @@
|
||||
"summary": "데이터 유형 {count}개, 최신 {relative}",
|
||||
"quiet": "{count}개 잠잠",
|
||||
"workouts": "운동",
|
||||
"medicationDoses": "약 복용"
|
||||
"medicationDoses": "약 복용",
|
||||
"cycleDays": "주기 기록일",
|
||||
"cycleDaysCount": "주기 기록일 ({count})"
|
||||
},
|
||||
"integrationFallback": {
|
||||
"description": "이 계정에 설정돼 있지만 전용 설정 카드는 없어요."
|
||||
|
||||
+3
-1
@@ -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
@@ -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
@@ -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
@@ -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 } },
|
||||
|
||||
@@ -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]!);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user