Merge branch 'v143/bundle' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 06:59:53 +02:00
commit c6d57e7ccf
21 files changed
+255 -192

No files matched your search

+6 -6
View File
@@ -1,13 +1,13 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"routeBaselineKbGz": {
"/page": 429,
"/page": 355,
"/insights/page": 304,
"/measurements/page": 411,
"/insights/mood/page": 379,
"/settings/[section]/page": 403
"/measurements/page": 335,
"/insights/mood/page": 334,
"/settings/[section]/page": 307
},
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 4860,
"totalClientKbGz": 4975,
"maxRechartsChunks": 1
}
@@ -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,
};
+61
View File
@@ -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;
+3 -5
View File
@@ -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;
+3 -53
View File
@@ -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({
/**
+2 -3
View File
@@ -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
+1 -1
View File
@@ -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);
}
+11
View File
@@ -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;
+83
View File
@@ -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 -83
View File
@@ -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,