mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
perf(bundle): keep Zod off the first load of four eager routes
zod 4.6 grew the shared Zod chunk from 63 to 89 KB gz (the new schema compiler and its memoizer ride along because every schema goes through the `z` namespace), and the dashboard, measurements, mood and settings routes each carried it for values that need no validation. Move the measurement source list, the notes limit, the screening check and the Coach source list into value-only modules the schemas are built from, import the settings classifier and response check from their leaves, and load the gate's managed settings and anamnesis sections on demand. Re-base the four route baselines to the measured values and the aggregate cap to 4975, with the reason in the budget comment.
This commit is contained in:
1 parent
a806676a71
commit
057fd3b49c
21 files changed
+255
-192
No files matched your search
@@ -29,7 +29,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
|
||||
import { isScreeningReminderType } from "@/lib/measurement-reminders/screening";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -15,7 +15,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
|
||||
import { isScreeningReminderType } from "@/lib/measurement-reminders/screening";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -37,7 +37,7 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
|
||||
import { isScreeningReminderType } from "@/lib/measurement-reminders/screening";
|
||||
import { relativeDueKey } from "@/lib/measurement-reminders/due-day";
|
||||
import {
|
||||
useMeasurementReminders,
|
||||
|
||||
@@ -92,7 +92,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
|
||||
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
|
||||
import { isScreeningReminderType } from "@/lib/measurement-reminders/screening";
|
||||
import {
|
||||
useMeasurementReminders,
|
||||
useMeasurementReminderMutations,
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
} from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit";
|
||||
import {
|
||||
getLastUsedMeasurementType,
|
||||
setLastUsedMeasurementType,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* keeps a bare URL. Kept out of the client component so the round trip stays
|
||||
* trivially unit-testable without a render harness.
|
||||
*/
|
||||
import { measurementSourceEnum } from "@/lib/validations/measurement";
|
||||
import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "./measurement-list-meta";
|
||||
|
||||
export interface MeasurementListFilters {
|
||||
@@ -24,7 +24,7 @@ export interface MeasurementListFilters {
|
||||
valueMax?: string;
|
||||
}
|
||||
|
||||
const SOURCE_SET = new Set<string>(measurementSourceEnum.options);
|
||||
const SOURCE_SET = new Set<string>(MEASUREMENT_SOURCES);
|
||||
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function dayOrUndefined(value: string | null): string | undefined {
|
||||
|
||||
@@ -72,7 +72,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels";
|
||||
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
convertGlucose,
|
||||
@@ -85,10 +85,8 @@ import {
|
||||
queryKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import {
|
||||
MEASUREMENT_NOTES_MAX_LENGTH,
|
||||
measurementSourceEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit";
|
||||
import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
@@ -255,7 +253,7 @@ function formatMeasurementSource(
|
||||
* management-list source filter. Derived from the shared Zod enum so a
|
||||
* future source addition surfaces here automatically.
|
||||
*/
|
||||
const MEASUREMENT_SOURCE_OPTIONS = measurementSourceEnum.options;
|
||||
const MEASUREMENT_SOURCE_OPTIONS = MEASUREMENT_SOURCES;
|
||||
|
||||
/**
|
||||
* v1.15.13 — convert a `<input type="date">` value (YYYY-MM-DD) into the
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { ComponentType } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
@@ -32,6 +33,24 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The gate loads its two record-scoped sections on demand. A static render
|
||||
// cannot wait for a lazy chunk, so the stand-in resolves each loader once,
|
||||
// before the first test, and renders the resolved component synchronously.
|
||||
const lazyLoads = vi.hoisted(() => [] as Promise<void>[]);
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: (load: () => Promise<ComponentType>) => {
|
||||
let Loaded: ComponentType | null = null;
|
||||
lazyLoads.push(
|
||||
load().then((component) => {
|
||||
Loaded = component;
|
||||
}),
|
||||
);
|
||||
return function DynamicStandIn() {
|
||||
return Loaded ? <Loaded /> : null;
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../managed-record-settings-section", () => ({
|
||||
ManagedRecordSettingsSection: () => <div data-slot="managed-settings" />,
|
||||
}));
|
||||
@@ -55,6 +74,10 @@ function render(section: "modules" | "integrations" | "anamnesis" | "score") {
|
||||
}
|
||||
|
||||
describe("RecordSettingsSectionGate", () => {
|
||||
beforeAll(async () => {
|
||||
await Promise.all(lazyLoads);
|
||||
});
|
||||
|
||||
it("does not mount an actor Settings component while auth is unresolved", () => {
|
||||
authRef.isLoading = true;
|
||||
authRef.active = null;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings";
|
||||
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response";
|
||||
|
||||
type ManagedIntegrationKey = keyof typeof INTEGRATION_DISPLAY_NAMES;
|
||||
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { CircleSlash, Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings";
|
||||
import { classifySettingsDestination } from "@/lib/record-settings/classification";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ManagedIntegrationStatus } from "@/components/settings/managed-integration-status";
|
||||
import { ManagedRecordSettingsSection } from "@/components/settings/managed-record-settings-section";
|
||||
import { RecordAnamnesisSection } from "@/components/settings/record-anamnesis-section";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
|
||||
// The two record-scoped surfaces load on demand. Both are forms that validate
|
||||
// with Zod (the managed settings patch, the allergy and family-history
|
||||
// entries), and every settings page renders this gate, so importing them
|
||||
// statically put Zod into the first load of every section for a branch that
|
||||
// only a shared or managed record ever reaches. The classifier is imported
|
||||
// from its leaf for the same reason: the `@/lib/record-settings` barrel also
|
||||
// re-exports the settings-patch schemas.
|
||||
const ManagedRecordSettingsSection = dynamic(() =>
|
||||
import("@/components/settings/managed-record-settings-section").then(
|
||||
(m) => m.ManagedRecordSettingsSection,
|
||||
),
|
||||
);
|
||||
const RecordAnamnesisSection = dynamic(() =>
|
||||
import("@/components/settings/record-anamnesis-section").then(
|
||||
(m) => m.RecordAnamnesisSection,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The record-CONTENT destinations, and what each one renders inside a record.
|
||||
*
|
||||
@@ -23,7 +40,7 @@ import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
* is what stops a new destination inheriting this one's forms.
|
||||
*/
|
||||
export const RECORD_CONTENT_SECTIONS: Partial<
|
||||
Record<SettingsSectionSlug, () => ReactNode>
|
||||
Record<SettingsSectionSlug, ComponentType>
|
||||
> = {
|
||||
anamnesis: RecordAnamnesisSection,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* The Coach scope's source values, as plain data.
|
||||
*
|
||||
* `coachScopeSourceSchema` in `./types` is built from this list. It lives
|
||||
* apart so a client surface that only needs the values (the insight pages'
|
||||
* metric scope, through `./tools/source-keys`) does not pull Zod into its
|
||||
* route's first load.
|
||||
*/
|
||||
export const COACH_SCOPE_SOURCES = [
|
||||
"bp",
|
||||
"weight",
|
||||
"pulse",
|
||||
"mood",
|
||||
"compliance",
|
||||
// ── v1.4.23 Apple Health additive ──
|
||||
// Optional scope toggles for the new HealthKit metrics. Web-only
|
||||
// accounts never carry data for these — `buildCoachSnapshot()` only
|
||||
// emits the matching block when Apple-Health rows exist.
|
||||
"hrv",
|
||||
"sleep",
|
||||
"resting_hr",
|
||||
"steps",
|
||||
"active_energy",
|
||||
"flights",
|
||||
"distance",
|
||||
"vo2_max",
|
||||
"body_temp",
|
||||
// ── v1.7.0 clustered sources ──
|
||||
// Cardiovascular composition / vascular metrics.
|
||||
"walking_hr",
|
||||
"respiratory_rate",
|
||||
"spo2",
|
||||
"pulse_wave_velocity",
|
||||
"vascular_age",
|
||||
// Body composition (mass + ratio metrics beyond plain weight).
|
||||
"body_fat",
|
||||
"fat_mass",
|
||||
"fat_free_mass",
|
||||
"muscle_mass",
|
||||
"lean_body_mass",
|
||||
"bone_mass",
|
||||
"total_body_water",
|
||||
"bmi",
|
||||
"visceral_fat",
|
||||
// Metabolic.
|
||||
"glucose",
|
||||
// Mobility & gait.
|
||||
"walking_steadiness",
|
||||
"walking_asymmetry",
|
||||
"walking_double_support",
|
||||
"walking_step_length",
|
||||
"walking_speed",
|
||||
// Environment / exposure.
|
||||
"audio_env",
|
||||
"audio_headphone",
|
||||
"audio_event",
|
||||
"daylight",
|
||||
"skin_temp",
|
||||
// Workout model (read from the `Workout` table, not `Measurement`).
|
||||
"workouts",
|
||||
] as const;
|
||||
@@ -17,10 +17,8 @@
|
||||
* either resolves here or is explicitly one of the dedicated-tool sources, so a
|
||||
* future source addition cannot silently fall through to a fabricated answer.
|
||||
*/
|
||||
import {
|
||||
coachScopeSourceSchema,
|
||||
type CoachScopeSource,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { COACH_SCOPE_SOURCES } from "@/lib/ai/coach/scope-sources";
|
||||
import type { CoachScopeSource } from "@/lib/ai/coach/types";
|
||||
|
||||
/** Sources that `get_metric_series` does NOT serve (they have own tools). */
|
||||
export const METRIC_SERIES_EXCLUDED_SOURCES: ReadonlySet<CoachScopeSource> =
|
||||
@@ -150,4 +148,4 @@ export const METRIC_SERIES_INVENTORY_SOURCES: ReadonlyArray<CoachScopeSource> =
|
||||
* matching source has rows, so probing the full set is presence-only.
|
||||
*/
|
||||
export const FULL_INVENTORY_SOURCE_SET: ReadonlyArray<CoachScopeSource> =
|
||||
coachScopeSourceSchema.options;
|
||||
COACH_SCOPE_SOURCES;
|
||||
@@ -8,6 +8,8 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { COACH_SCOPE_SOURCES } from "./scope-sources";
|
||||
|
||||
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
|
||||
import type { CoachSuggestedAction } from "./suggest-action";
|
||||
import type { CoachToolName } from "./tools/definitions";
|
||||
@@ -53,59 +55,7 @@ export const coachScopeWindowSchema = z.enum([
|
||||
"allTime",
|
||||
]);
|
||||
|
||||
export const coachScopeSourceSchema = z.enum([
|
||||
"bp",
|
||||
"weight",
|
||||
"pulse",
|
||||
"mood",
|
||||
"compliance",
|
||||
// ── v1.4.23 Apple Health additive ──
|
||||
// Optional scope toggles for the new HealthKit metrics. Web-only
|
||||
// accounts never carry data for these — `buildCoachSnapshot()` only
|
||||
// emits the matching block when Apple-Health rows exist.
|
||||
"hrv",
|
||||
"sleep",
|
||||
"resting_hr",
|
||||
"steps",
|
||||
"active_energy",
|
||||
"flights",
|
||||
"distance",
|
||||
"vo2_max",
|
||||
"body_temp",
|
||||
// ── v1.7.0 clustered sources ──
|
||||
// Cardiovascular composition / vascular metrics.
|
||||
"walking_hr",
|
||||
"respiratory_rate",
|
||||
"spo2",
|
||||
"pulse_wave_velocity",
|
||||
"vascular_age",
|
||||
// Body composition (mass + ratio metrics beyond plain weight).
|
||||
"body_fat",
|
||||
"fat_mass",
|
||||
"fat_free_mass",
|
||||
"muscle_mass",
|
||||
"lean_body_mass",
|
||||
"bone_mass",
|
||||
"total_body_water",
|
||||
"bmi",
|
||||
"visceral_fat",
|
||||
// Metabolic.
|
||||
"glucose",
|
||||
// Mobility & gait.
|
||||
"walking_steadiness",
|
||||
"walking_asymmetry",
|
||||
"walking_double_support",
|
||||
"walking_step_length",
|
||||
"walking_speed",
|
||||
// Environment / exposure.
|
||||
"audio_env",
|
||||
"audio_headphone",
|
||||
"audio_event",
|
||||
"daylight",
|
||||
"skin_temp",
|
||||
// Workout model (read from the `Workout` table, not `Measurement`).
|
||||
"workouts",
|
||||
]);
|
||||
export const coachScopeSourceSchema = z.enum(COACH_SCOPE_SOURCES);
|
||||
|
||||
export const coachScopeSchema = z.object({
|
||||
/**
|
||||
|
||||
@@ -13,10 +13,9 @@
|
||||
* type error, and `measurement-source-label-coverage.test.ts` proves the
|
||||
* keys resolve in the message bundle.
|
||||
*/
|
||||
import { measurementSourceEnum } from "@/lib/validations/measurement";
|
||||
import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources";
|
||||
|
||||
export type MeasurementSourceValue =
|
||||
(typeof measurementSourceEnum.options)[number];
|
||||
export type MeasurementSourceValue = (typeof MEASUREMENT_SOURCES)[number];
|
||||
|
||||
/**
|
||||
* Settings → Sources ("Source priority by metric") and the sleep
|
||||
|
||||
@@ -41,8 +41,8 @@ import {
|
||||
measurementTypeEnum,
|
||||
glucoseContextEnum,
|
||||
validateMeasurementRange,
|
||||
MEASUREMENT_NOTES_MAX_LENGTH,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { isStableExternalId } from "@/lib/validations/external-id";
|
||||
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* v1.27.6 — the reminder types that are SCREENINGS, not numeric readings.
|
||||
* Every surface with a per-reminder primary action branches on this: a
|
||||
* screening routes to the check-in page (`/mental-wellbeing`) instead of
|
||||
* opening the numeric MeasurementForm — a score is never typed in, it
|
||||
* falls out of a completed test (which then auto-satisfies the reminder).
|
||||
*
|
||||
* Plain data in its own module, apart from
|
||||
* `@/lib/validations/measurement-reminders`, so the dashboard's Vorsorge card
|
||||
* can branch on it without pulling Zod into the dashboard's first load.
|
||||
*/
|
||||
const SCREENING_REMINDER_TYPES: ReadonlySet<string> = new Set([
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
/** Whether a reminder's `measurementType` targets a screening check-in. */
|
||||
export function isScreeningReminderType(
|
||||
type: string | null | undefined,
|
||||
): boolean {
|
||||
return type != null && SCREENING_REMINDER_TYPES.has(type);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* v1.6.0 — notes cap raised from 25 to 200. A 25-char limit could not
|
||||
* hold a meaningful clinical note ("took after large meal, felt dizzy
|
||||
* standing up"). The DB column is unbounded `String?`; this is the
|
||||
* single source of truth the schemas and the client char-counters import.
|
||||
*
|
||||
* Its own module so the entry form can read the number without pulling the
|
||||
* measurement validation module, and Zod with it, into its route's first
|
||||
* load.
|
||||
*/
|
||||
export const MEASUREMENT_NOTES_MAX_LENGTH = 200;
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The `MeasurementSource` values, as plain data.
|
||||
*
|
||||
* Kept out of `@/lib/validations/measurement` so a client surface that only
|
||||
* needs the list (the source labels, the measurement list's URL filter) does
|
||||
* not pull Zod into its route's first load. The Zod enum in the validation
|
||||
* module is built from this list, so the two cannot drift.
|
||||
*/
|
||||
export const MEASUREMENT_SOURCES = [
|
||||
"MANUAL",
|
||||
"WITHINGS",
|
||||
"IMPORT",
|
||||
"APPLE_HEALTH",
|
||||
// v1.10.0 — computed scores (WX-C). Server-owned / read-only: a `COMPUTED`
|
||||
// row is minted by a nightly engine, never written by a client. It is part
|
||||
// of this enum so the read/response shapes (and the iOS decoder) can decode
|
||||
// the rows it surfaces; the client-facing write surfaces reject it — see
|
||||
// `WRITABLE_MEASUREMENT_SOURCES` and the batch route's `batchSourceEnum`.
|
||||
"COMPUTED",
|
||||
// v1.11.0 — WHOOP integration. Native WHOOP scores ingest server-side (no
|
||||
// client write path). Part of this enum so the read/response shapes (and
|
||||
// the iOS decoder) can decode the rows it surfaces.
|
||||
"WHOOP",
|
||||
// v1.12.0 — Fitbit/Pixel integration via the Google Health API. Server-owned
|
||||
// (no client write path), exactly like WHOOP. Part of this enum so the
|
||||
// read/response shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"FITBIT",
|
||||
// v1.17.0 — Nightscout glucose integration (F1). Server-owned (no client
|
||||
// write path), exactly like WHOOP/FITBIT. Part of this enum so the
|
||||
// read/response shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"NIGHTSCOUT",
|
||||
// v1.17.0 — Polar OAuth integration (F4). Server-owned (no client write
|
||||
// path), exactly like WHOOP/FITBIT. Part of this enum so the read/response
|
||||
// shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"POLAR",
|
||||
// v1.17.0 — Oura OAuth integration (F4). Server-owned (no client write
|
||||
// path), exactly like WHOOP/FITBIT. Part of this enum so the read/response
|
||||
// shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"OURA",
|
||||
// v1.19.2 — Telegram numeric-reply capture. A reply to a measurement
|
||||
// reminder is written server-side from the chat-bound webhook (not the
|
||||
// cookie/Bearer client write path), so it carries its own source label.
|
||||
// Part of this enum so the read/response shapes (and the iOS decoder) can
|
||||
// decode the rows it surfaces; the client-facing write surfaces still
|
||||
// reject it (see `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist).
|
||||
"TELEGRAM",
|
||||
// v1.22.0 — measurements logged through the confirmed remote MCP write
|
||||
// surface under a `health:write`-scoped token. Server-resolved (the row is
|
||||
// written in-process over `/mcp`, never through the cookie/Bearer client
|
||||
// write path), so it is excluded from `WRITABLE_MEASUREMENT_SOURCES`; part
|
||||
// of this enum so the read/response shapes (and the iOS decoder) can decode
|
||||
// the rows it surfaces.
|
||||
"MCP",
|
||||
// v1.26.0 — Fitbit/Pixel + Wear OS via the Google Health API. Server-owned
|
||||
// (no client write path), exactly like WHOOP/FITBIT. Part of this enum so the
|
||||
// read/response shapes (and the iOS decoder) can decode the rows it surfaces;
|
||||
// deliberately absent from `WRITABLE_MEASUREMENT_SOURCES` + the batch
|
||||
// allowlist so a client can never forge a GOOGLE_HEALTH-attributed row.
|
||||
"GOOGLE_HEALTH",
|
||||
// v1.28.x — Strava OAuth integration. Native Strava activities ingest
|
||||
// server-side as `source = STRAVA` Workout rows (no client write path),
|
||||
// exactly like WHOOP/FITBIT. Part of this enum so the read/response shapes
|
||||
// (and the iOS decoder) can decode the rows it surfaces; deliberately absent
|
||||
// from `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist so a client can
|
||||
// never forge a STRAVA-attributed row.
|
||||
"STRAVA",
|
||||
// v1.38.x — measurements pushed in under a `measurements:write` Bearer
|
||||
// token (a home-automation bridge, a scale's own uploader). Server-resolved
|
||||
// from the credential rather than named by the caller, so it is deliberately
|
||||
// absent from `WRITABLE_MEASUREMENT_SOURCES` and the batch allowlist — a
|
||||
// source a client could assert would prove nothing about where the reading
|
||||
// came from. Part of this enum so the read/response shapes (and the iOS
|
||||
// decoder) can decode the rows it surfaces. Unlike the server-owned sources
|
||||
// above these rows stay value-editable; see
|
||||
// `USER_CORRECTABLE_MEASUREMENT_SOURCES`.
|
||||
"EXTERNAL",
|
||||
// v1.42 — rows imported from an Android Health Connect export (#972).
|
||||
// Server-owned: only the Health Connect import worker writes them, so the
|
||||
// value is deliberately absent from `WRITABLE_MEASUREMENT_SOURCES` and the
|
||||
// batch allowlist, like IMPORT. Part of this enum so the read/response
|
||||
// shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"HEALTH_CONNECT",
|
||||
] as const;
|
||||
@@ -82,27 +82,6 @@ export type MeasurementReminderType = z.infer<
|
||||
typeof measurementReminderTypeEnum
|
||||
>;
|
||||
|
||||
/**
|
||||
* v1.27.6 — the reminder types that are SCREENINGS, not numeric readings.
|
||||
* Every surface with a per-reminder primary action branches on this: a
|
||||
* screening routes to the check-in page (`/mental-wellbeing`) instead of
|
||||
* opening the numeric MeasurementForm — a score is never typed in, it
|
||||
* falls out of a completed test (which then auto-satisfies the reminder).
|
||||
*/
|
||||
export const SCREENING_REMINDER_TYPES: ReadonlySet<string> = new Set([
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"WHO5_SCORE",
|
||||
"SCI_SCORE",
|
||||
]);
|
||||
|
||||
/** Whether a reminder's `measurementType` targets a screening check-in. */
|
||||
export function isScreeningReminderType(
|
||||
type: string | null | undefined,
|
||||
): boolean {
|
||||
return type != null && SCREENING_REMINDER_TYPES.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-5545 RRULE guard. The recurrence engine parses it; here we only
|
||||
* bound the length and require the `FREQ=` token so a typo doesn't reach
|
||||
|
||||
@@ -3,6 +3,8 @@ import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
|
||||
import { validateEntryInstant } from "./entry-instant";
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources";
|
||||
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit";
|
||||
|
||||
export const measurementTypeEnum = z.enum([
|
||||
"WEIGHT",
|
||||
@@ -167,81 +169,7 @@ export const glucoseContextEnum = z.enum([
|
||||
"BEDTIME",
|
||||
]);
|
||||
|
||||
export const measurementSourceEnum = z.enum([
|
||||
"MANUAL",
|
||||
"WITHINGS",
|
||||
"IMPORT",
|
||||
"APPLE_HEALTH",
|
||||
// v1.10.0 — computed scores (WX-C). Server-owned / read-only: a `COMPUTED`
|
||||
// row is minted by a nightly engine, never written by a client. It is part
|
||||
// of this enum so the read/response shapes (and the iOS decoder) can decode
|
||||
// the rows it surfaces; the client-facing write surfaces reject it — see
|
||||
// `WRITABLE_MEASUREMENT_SOURCES` and the batch route's `batchSourceEnum`.
|
||||
"COMPUTED",
|
||||
// v1.11.0 — WHOOP integration. Native WHOOP scores ingest server-side (no
|
||||
// client write path). Part of this enum so the read/response shapes (and
|
||||
// the iOS decoder) can decode the rows it surfaces.
|
||||
"WHOOP",
|
||||
// v1.12.0 — Fitbit/Pixel integration via the Google Health API. Server-owned
|
||||
// (no client write path), exactly like WHOOP. Part of this enum so the
|
||||
// read/response shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"FITBIT",
|
||||
// v1.17.0 — Nightscout glucose integration (F1). Server-owned (no client
|
||||
// write path), exactly like WHOOP/FITBIT. Part of this enum so the
|
||||
// read/response shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"NIGHTSCOUT",
|
||||
// v1.17.0 — Polar OAuth integration (F4). Server-owned (no client write
|
||||
// path), exactly like WHOOP/FITBIT. Part of this enum so the read/response
|
||||
// shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"POLAR",
|
||||
// v1.17.0 — Oura OAuth integration (F4). Server-owned (no client write
|
||||
// path), exactly like WHOOP/FITBIT. Part of this enum so the read/response
|
||||
// shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"OURA",
|
||||
// v1.19.2 — Telegram numeric-reply capture. A reply to a measurement
|
||||
// reminder is written server-side from the chat-bound webhook (not the
|
||||
// cookie/Bearer client write path), so it carries its own source label.
|
||||
// Part of this enum so the read/response shapes (and the iOS decoder) can
|
||||
// decode the rows it surfaces; the client-facing write surfaces still
|
||||
// reject it (see `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist).
|
||||
"TELEGRAM",
|
||||
// v1.22.0 — measurements logged through the confirmed remote MCP write
|
||||
// surface under a `health:write`-scoped token. Server-resolved (the row is
|
||||
// written in-process over `/mcp`, never through the cookie/Bearer client
|
||||
// write path), so it is excluded from `WRITABLE_MEASUREMENT_SOURCES`; part
|
||||
// of this enum so the read/response shapes (and the iOS decoder) can decode
|
||||
// the rows it surfaces.
|
||||
"MCP",
|
||||
// v1.26.0 — Fitbit/Pixel + Wear OS via the Google Health API. Server-owned
|
||||
// (no client write path), exactly like WHOOP/FITBIT. Part of this enum so the
|
||||
// read/response shapes (and the iOS decoder) can decode the rows it surfaces;
|
||||
// deliberately absent from `WRITABLE_MEASUREMENT_SOURCES` + the batch
|
||||
// allowlist so a client can never forge a GOOGLE_HEALTH-attributed row.
|
||||
"GOOGLE_HEALTH",
|
||||
// v1.28.x — Strava OAuth integration. Native Strava activities ingest
|
||||
// server-side as `source = STRAVA` Workout rows (no client write path),
|
||||
// exactly like WHOOP/FITBIT. Part of this enum so the read/response shapes
|
||||
// (and the iOS decoder) can decode the rows it surfaces; deliberately absent
|
||||
// from `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist so a client can
|
||||
// never forge a STRAVA-attributed row.
|
||||
"STRAVA",
|
||||
// v1.38.x — measurements pushed in under a `measurements:write` Bearer
|
||||
// token (a home-automation bridge, a scale's own uploader). Server-resolved
|
||||
// from the credential rather than named by the caller, so it is deliberately
|
||||
// absent from `WRITABLE_MEASUREMENT_SOURCES` and the batch allowlist — a
|
||||
// source a client could assert would prove nothing about where the reading
|
||||
// came from. Part of this enum so the read/response shapes (and the iOS
|
||||
// decoder) can decode the rows it surfaces. Unlike the server-owned sources
|
||||
// above these rows stay value-editable; see
|
||||
// `USER_CORRECTABLE_MEASUREMENT_SOURCES`.
|
||||
"EXTERNAL",
|
||||
// v1.42 — rows imported from an Android Health Connect export (#972).
|
||||
// Server-owned: only the Health Connect import worker writes them, so the
|
||||
// value is deliberately absent from `WRITABLE_MEASUREMENT_SOURCES` and the
|
||||
// batch allowlist, like IMPORT. Part of this enum so the read/response
|
||||
// shapes (and the iOS decoder) can decode the rows it surfaces.
|
||||
"HEALTH_CONNECT",
|
||||
]);
|
||||
export const measurementSourceEnum = z.enum(MEASUREMENT_SOURCES);
|
||||
|
||||
/**
|
||||
* v1.10.0 — computed scores (WX-C). The subset of `MeasurementSource` a
|
||||
@@ -535,14 +463,6 @@ export function validateMeasurementRange(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.6.0 — notes cap raised from 25 to 200. A 25-char limit could not
|
||||
* hold a meaningful clinical note ("took after large meal, felt dizzy
|
||||
* standing up"). The DB column is unbounded `String?`; this is the
|
||||
* single source of truth the client char-counters import.
|
||||
*/
|
||||
export const MEASUREMENT_NOTES_MAX_LENGTH = 200;
|
||||
|
||||
export const createMeasurementSchema = z
|
||||
.object({
|
||||
type: measurementTypeEnum,
|
||||
|
||||
Reference in new issue
Block a user