Lighten the client shell and finish four client surfaces

- Load each settings section on its own: the section route imported all
  twenty-one statically. A preload starts the chunk beside the record
  gate's read, and the loading state paints the settings card shell.
  /settings/[section] goes from 611 to 403 KB gz.
- Take Zod out of the shell every route shares: the account-access parse
  run on each boot is plain TypeScript now, held to the published schema
  by an equivalence test over good and broken blocks. The canonical unit
  table moves to its own module, so the display transform no longer
  pulls Zod in either. Layout entry 199 to 136 KB gz; /insights 369 to
  304, /medications 428 to 365, /labs 400 to 337.
- Open a saved Coach conversation at its last answer: an instant jump
  instead of the smooth scroll that lost the end, then follow late
  layout (charts, tables) for a few seconds until the reader scrolls.
- Show the document archive as a month grid of preview tiles, two
  across on a phone and three or four on a desktop, with the month
  headings kept.
- Add the admin card for the measurement table maintenance: a status
  read on the maintenance route, one calm confirmation, and the run's
  state while it is queued, running, finished or stopped.
- Re-base the bundle budget: /insights at 304, /settings/[section]
  watched at 403, aggregate cap 4335 with the reason written down.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 11:25:11 +02:00
1 parent bb563b6506
commit 12e422c25e
38 files changed
+2505 -406

No files matched your search

@@ -0,0 +1,129 @@
/**
* What the admin card shows about the measurement table maintenance: the
* newest run and where it stands, whether the compaction-tombstone purge is
* still at work (the run refuses to start while it is), and the table's size
* right now.
*
* Read straight from `pgboss.job`, the same source `job-failures.ts` reads,
* and fail-soft the same way: a web-only deployment has no `pgboss` schema,
* and the card says "no background worker" instead of failing.
*/
import { prisma } from "@/lib/db";
import { MEASUREMENT_MAINTENANCE_QUEUE } from "@/lib/jobs/measurement-maintenance";
const PURGE_QUEUE = "compaction-tombstone-purge";
/** Where the newest run stands, in the words the card uses. */
export type MaintenanceRunState = "queued" | "running" | "completed" | "failed";
/**
* How a finished run ended. `completed` did the work; the two refusals
* finished without touching the table.
*/
export type MaintenanceRunOutcome =
"completed" | "refused_purge_running" | "already_running";
export interface MeasurementMaintenanceStatus {
/** False when there is no background queue to ask (web-only deployment). */
available: boolean;
/** The purge is queued or running; a new run would refuse to start. */
purgePending: boolean;
run: {
state: MaintenanceRunState;
requestedAt: string;
startedAt: string | null;
finishedAt: string | null;
/** Only for `completed`: what the finished run actually did. */
outcome: MaintenanceRunOutcome | null;
} | null;
/** `measurements` with its indexes and TOAST, and its indexes alone. */
sizes: { tableBytes: number; indexBytes: number } | null;
}
const OUTCOMES: ReadonlySet<string> = new Set([
"completed",
"refused_purge_running",
"already_running",
]);
/** pg-boss's row state → the card's run state. */
export function runStateOf(state: string): MaintenanceRunState {
if (state === "created" || state === "retry") return "queued";
if (state === "active") return "running";
if (state === "completed") return "completed";
return "failed";
}
async function readSizes(): Promise<MeasurementMaintenanceStatus["sizes"]> {
try {
const rows = await prisma.$queryRaw<
Array<{ table_bytes: bigint; index_bytes: bigint }>
>`
SELECT pg_total_relation_size('measurements'::regclass) AS table_bytes,
pg_indexes_size('measurements'::regclass) AS index_bytes
`;
const row = rows[0];
if (!row) return null;
return {
tableBytes: Number(row.table_bytes),
indexBytes: Number(row.index_bytes),
};
} catch {
return null;
}
}
export async function readMeasurementMaintenanceStatus(): Promise<MeasurementMaintenanceStatus> {
const sizes = await readSizes();
try {
const [runs, purge] = await Promise.all([
prisma.$queryRaw<
Array<{
state: string;
created_on: Date;
started_on: Date | null;
completed_on: Date | null;
outcome: string | null;
}>
>`
SELECT state, created_on, started_on, completed_on,
output->'did'->>'outcome' AS outcome
FROM pgboss.job
WHERE name = ${MEASUREMENT_MAINTENANCE_QUEUE}
ORDER BY created_on DESC
LIMIT 1
`,
prisma.$queryRaw<Array<{ n: number }>>`
SELECT count(*)::int AS n
FROM pgboss.job
WHERE name = ${PURGE_QUEUE}
AND state IN ('created', 'retry', 'active')
`,
]);
const row = runs[0];
const state = row ? runStateOf(row.state) : null;
return {
available: true,
purgePending: (purge[0]?.n ?? 0) > 0,
run:
row && state
? {
state,
requestedAt: row.created_on.toISOString(),
startedAt: row.started_on?.toISOString() ?? null,
finishedAt: row.completed_on?.toISOString() ?? null,
outcome:
state === "completed" &&
row.outcome !== null &&
OUTCOMES.has(row.outcome)
? (row.outcome as MaintenanceRunOutcome)
: null,
}
: null,
sizes,
};
} catch {
// No `pgboss` schema (web-only deployment) or no permission to read it.
return { available: false, purgePending: false, run: null, sizes };
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ import {
resolveGlucoseUnit,
type GlucoseUnit,
} from "@/lib/glucose";
import { getUnitForType } from "@/lib/validations/measurement";
import { getUnitForType } from "@/lib/measurements/unit-map";
export type UnitPreference = "metric" | "imperial";
+163
View File
@@ -0,0 +1,163 @@
/**
* The canonical storage unit of every measurement type.
*
* Its own module, with no imports, because both ends read it: the write
* validation in `validations/measurement.ts` and the client's display
* transform (`display-transform.ts`), which runs on nearly every chart and
* tile. Living inside the validation module it dragged Zod into each of those
* routes for one table lookup. `validations/measurement.ts` re-exports
* `getUnitForType`, so server callers keep their import.
*/
const unitMap: Record<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
// v1.4.23 — sleep duration shifted from hours to minutes so HealthKit
// category-sample stages can be stored without precision loss. Older
// surfaces that need hours convert at read time (`minutes / 60`).
SLEEP_DURATION: "minutes",
ACTIVITY_STEPS: "steps",
BLOOD_GLUCOSE: "mg/dL",
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
// ── v1.4.23 Apple Health canonical units ──
HEART_RATE_VARIABILITY: "ms",
RESTING_HEART_RATE: "bpm",
ACTIVE_ENERGY_BURNED: "kcal",
FLIGHTS_CLIMBED: "flights",
WALKING_RUNNING_DISTANCE: "m",
VO2_MAX: "mL/(kg·min)",
BODY_TEMPERATURE: "celsius",
// ── v1.4.25 W5d Withings full coverage ──
FAT_FREE_MASS: "kg",
FAT_MASS: "kg",
MUSCLE_MASS: "kg",
// Distinct from BODY_TEMPERATURE — surface temps run ~32 °C; sharing
// the bucket would corrupt analytics. Same canonical unit (°C).
SKIN_TEMPERATURE: "celsius",
PULSE_WAVE_VELOCITY: "m/s",
VASCULAR_AGE: "years",
// Withings reports visceral fat as a 1–12 rating, not a percent. The
// string mirrors what Withings prints in Health Mate.
VISCERAL_FAT: "rating",
// ── v1.4.25 W8d Apple Health server-prep ──
// Sound-pressure level — A-weighted decibels (dBA). HealthKit reports
// both audio-exposure metrics in dBASPL; we store the unweighted "dBA"
// label because the A-weighting is implicit (every HealthKit audio
// sample carries it). 30 dBA = quiet bedroom; 140 dBA = pain threshold.
AUDIO_EXPOSURE_ENV: "dBA",
AUDIO_EXPOSURE_HEADPHONE: "dBA",
// Daily-rollup pattern (one sample = one day's outdoor-light minutes).
// 0–1440 covers the 24-hour day; in practice indoor users sit near 0
// and outdoor athletes accumulate a few hours.
TIME_IN_DAYLIGHT: "minutes",
// ── v1.5.5 iOS-coord additions ──
// Respiratory rate breaths-per-minute — the HK identifier ships as
// `count/min`; we keep the more conventional clinical label.
RESPIRATORY_RATE: "breaths/min",
// BMI kg/m² — HealthKit ships the unitless ratio; the canonical
// display string mirrors clinical convention.
BODY_MASS_INDEX: "kg/m²",
// Lean body mass kg — body-composition partner to FAT_MASS.
LEAN_BODY_MASS: "kg",
// Walking heart rate average bpm — daily rollup; distinct from
// RESTING_HEART_RATE (sleep-window minimum) and spot PULSE.
WALKING_HEART_RATE_AVERAGE: "bpm",
// Walking gait percent (0-100 after server-side ×100 scaling).
// Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION
// — see the project convention block in `apple-health-mapping.ts`.
WALKING_ASYMMETRY: "%",
WALKING_DOUBLE_SUPPORT: "%",
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
// Step length is metres; speed is metres per second. Both flow
// raw on the wire — no server-side scaling. The unit strings
// match HealthKit's `m` / `m/s` defaults.
WALKING_STEP_LENGTH: "m",
WALKING_SPEED: "m/s",
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
// Cardio recovery is the bpm drop one minute after peak exercise.
CARDIO_RECOVERY: "bpm",
// Overnight wrist temperature in °C (absolute reading; Apple's own
// display frames it as a baseline deviation, we store the reading).
WRIST_TEMPERATURE: "celsius",
// Hard-fall detections — a plain count.
FALL_COUNT: "count",
// Apple's estimated six-minute-walk-test distance in metres.
SIX_MINUTE_WALK_DISTANCE: "m",
// Stair gait speeds — raw metres-per-second (no scaling).
STAIR_ASCENT_SPEED: "m/s",
STAIR_DESCENT_SPEED: "m/s",
// Per-night breathing-disturbance index — a unitless count Apple
// classifies as NotElevated / Elevated.
BREATHING_DISTURBANCES: "count",
// ── v1.10.0 — categorical events (WX-B) ──
// EVENT rows are dimensionless occurrences (value is always 1). The
// canonical unit is the bare "event" so any accidental numeric surfacing
// reads sensibly; the awareness timeline never displays the value.
IRREGULAR_RHYTHM_NOTIFICATION: "event",
HIGH_HEART_RATE_EVENT: "event",
LOW_HEART_RATE_EVENT: "event",
WALKING_STEADINESS_EVENT: "event",
BREATHING_DISTURBANCE_EVENT: "event",
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived 0–100 wellness scores. The canonical unit is the bare
// "score" so the value reads sensibly anywhere it surfaces.
RECOVERY_SCORE: "score",
STRESS_SCORE: "score",
STRAIN_SCORE: "score",
// ── v1.11.0 — WHOOP-native score classes ──
// RMSSD HRV is in milliseconds, same canonical unit as the SDNN
// HEART_RATE_VARIABILITY (different estimator, same dimension).
HRV_RMSSD: "ms",
// Day / workout strain ride WHOOP's bounded 0–21 scale; the bare "score"
// unit reads sensibly wherever the value surfaces (distinct from the
// 0–100 COMPUTED STRAIN_SCORE).
DAY_STRAIN: "score",
WORKOUT_STRAIN: "score",
// Sleep quality percentages (0–100).
SLEEP_PERFORMANCE: "%",
SLEEP_EFFICIENCY: "%",
SLEEP_CONSISTENCY: "%",
// Recommended sleep duration in minutes (WHOOP reports ms; mapper ÷60000).
SLEEP_NEED: "minutes",
// Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the
// device value round-trips rather than being converted to kcal).
ENERGY_EXPENDITURE_KJ: "kJ",
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
// Daily average / max heart rate bpm — whole-cycle aggregates, distinct
// from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE.
AVERAGE_HEART_RATE: "bpm",
MAX_HEART_RATE: "bpm",
// Per-night sleep disturbance tally — a plain integer count.
SLEEP_DISTURBANCE_COUNT: "count",
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
ANS_CHARGE: "score",
CARDIO_LOAD: "score",
// ── v1.17.1 — Oura coverage completion ──
// Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores.
SLEEP_SCORE: "score",
// Signed body-temperature deviation in °C (Oura nightly baseline offset).
BODY_TEMPERATURE_DEVIATION: "celsius",
// ── v1.19.0 — Oura resilience ──
// Ordinal level scale (1=limited … 5=exceptional) — the categorical band
// encoded into the numeric value. See RESILIENCE_LEVELS in src/lib/oura/client.
RESILIENCE: "level",
// ── v1.25 — clinical-signals wave ──
PHQ9_SCORE: "score",
GAD7_SCORE: "score",
// ── v1.27.9 — screening scores ──
WHO5_SCORE: "score",
SCI_SCORE: "score",
GRIP_STRENGTH: "kg",
PAIN_NRS: "score",
WAIST_CIRCUMFERENCE: "cm",
WAIST_TO_HEIGHT: "ratio",
};
export function getUnitForType(type: string): string {
return unitMap[type] ?? "unknown";
}
+61
View File
@@ -275,8 +275,69 @@ const adminMeasurementMaintenanceResult = z
})
.meta({ id: "AdminMeasurementMaintenanceResult" });
const adminMeasurementMaintenanceStatus = z
.object({
available: z
.boolean()
.describe(
"False when there is no background queue to ask (a web-only deployment); nothing can be started then.",
),
purgePending: z
.boolean()
.describe(
"The compaction-tombstone purge is queued or running. A run started now finishes at once with `refused_purge_running`.",
),
run: z
.object({
state: z.enum(["queued", "running", "completed", "failed"]),
requestedAt: z.iso.datetime(),
startedAt: z.iso.datetime().nullable(),
finishedAt: z.iso.datetime().nullable(),
outcome: z
.enum(["completed", "refused_purge_running", "already_running"])
.nullable()
.describe(
"How a `completed` run ended: `completed` did the work, the two refusals finished without touching the table. Null for any other state.",
),
})
.nullable()
.describe(
"The newest run pg-boss still keeps (terminal rows live seven days), or null when there is none.",
),
sizes: z
.object({
tableBytes: z
.number()
.int()
.describe("`measurements` with its indexes and TOAST."),
indexBytes: z.number().int().describe("Its indexes alone."),
})
.nullable(),
})
.meta({ id: "AdminMeasurementMaintenanceStatus" });
export const adminDiagnosticPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/admin/maintenance/measurements": {
get: {
tags: ["Admin"],
summary: "Where measurement table maintenance stands",
description:
"The newest maintenance run and its state, whether the compaction-tombstone purge is still at work, and the table's size right now. The admin card polls it while a run is queued or running. Cookie auth only: `requireAdmin()` refuses every Bearer caller.",
responses: {
"200": {
description: "The status.",
content: {
"application/json": {
schema: dataEnvelope(
adminMeasurementMaintenanceStatus,
"AdminMeasurementMaintenanceStatusResponse",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Admin"],
summary: "Queue measurement table maintenance",
+2
View File
@@ -48,6 +48,8 @@ export const adminKeys = {
adminBackupRestores: () => ["admin", "backup-restores"] as const,
/** v1.23 — encryption coverage + rotation progress (admin Encryption section). */
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
adminMeasurementMaintenance: () =>
["admin", "maintenance", "measurements"] as const,
/** v1.40.0 — the "Back up your encryption key" step (admin + dashboard banner). */
adminEncryptionKeyBackup: () =>
["admin", "encryption", "key-backup"] as const,
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { parseAccountAccess } from "../account-access-schema";
import {
parseAccountAccess,
validateAccountAccessBlock,
} from "../account-access-parse";
import { accountAccessBlockSchema } from "../account-access-schema";
const sharedEntry = {
accountId: "record-a",
@@ -220,3 +224,108 @@ describe("parseAccountAccess", () => {
).toBeNull();
});
});
/**
* The browser parse carries no Zod (it rides the shell every route shares),
* so it is a second statement of the published schema. This holds the two to
* one verdict: over every fixture below, and every single-field breakage of
* each, the hand-written check accepts exactly what the schema accepts and
* returns the same stripped value. Change one without the other and this
* fails with the input that tells them apart.
*/
describe("validateAccountAccessBlock agrees with the published schema", () => {
const BAD_VALUES: unknown[] = [
undefined,
null,
0,
1,
true,
"",
"bogus",
[],
["labs"],
["labs", "labs"],
["nope"],
{},
];
const managerEntry = {
...sharedEntry,
accountId: "record-m",
username: "record-m",
access: "write",
level: "manage",
recordKind: "managed",
sections: null,
canWrite: true,
writableDomains: ["measurements", "mind"],
manageableDomains: ["measurements"],
};
const writerEntry = {
...sharedEntry,
accountId: "record-w",
username: "record-w",
access: "write",
level: "write",
sections: ["labs", "measurements"],
canWrite: true,
writableDomains: ["measurements"],
};
const bases: Record<string, unknown>[] = [
accessBlock(),
{ accounts: [], active: null, recordKind: "self", canSwitch: false },
{
accounts: [managerEntry, writerEntry],
active: null,
recordKind: "self",
canSwitch: true,
},
{
accounts: [writerEntry],
active: { ...writerEntry, extra: "stripped" },
recordKind: "shared",
canSwitch: true,
},
];
function variants(): unknown[] {
const out: unknown[] = [...bases, null, "x", [], 42];
for (const base of bases) {
for (const key of Object.keys(base)) {
for (const bad of BAD_VALUES) out.push({ ...base, [key]: bad });
}
const accounts = base.accounts as Record<string, unknown>[];
accounts.forEach((entry, index) => {
for (const key of [...Object.keys(entry), "unknownKey"]) {
for (const bad of BAD_VALUES) {
const broken = { ...entry, [key]: bad };
const next = [...accounts];
next[index] = broken;
out.push({ ...base, accounts: next });
if (base.active) out.push({ ...base, active: broken });
}
}
});
}
return out;
}
it("accepts and rejects the same blocks, with the same output", () => {
const corpus = variants();
expect(corpus.length).toBeGreaterThan(500);
let accepted = 0;
for (const input of corpus) {
const zod = accountAccessBlockSchema.safeParse(input);
const mine = validateAccountAccessBlock(input);
expect(mine !== null, JSON.stringify(input)).toBe(zod.success);
if (zod.success) {
expect(mine, JSON.stringify(input)).toEqual(zod.data);
accepted += 1;
}
}
// Both arms exercised: the corpus is not all-reject or all-accept.
expect(accepted).toBeGreaterThan(10);
expect(accepted).toBeLessThan(corpus.length - 100);
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* The browser's parse of the account-access block on `GET /api/auth/me`.
*
* Plain TypeScript, no Zod, on purpose. `use-auth.ts` runs this parse on every
* app boot, so whatever it imports lands in the shell every route shares, and
* this parse was the shell's only Zod import: about 60 KB gzip on every first
* paint for one structural check. A tree-shaken `zod/mini` build measured
* almost the same under Turbopack, because the shared core comes along either
* way.
*
* The Zod schema stays the contract: `account-access-schema.ts` publishes it
* to OpenAPI, and `account-access-parse.test.ts` runs both over the same
* corpus of good and broken blocks and fails the moment they disagree on
* whether a block is accepted or on what comes out of it.
*/
import type {
AccountAccess,
AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
const DOMAINS: ReadonlySet<string> = new Set(SHARE_DOMAINS);
type Raw = Record<string, unknown>;
function isRecord(value: unknown): value is Raw {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length >= 1;
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
function domainList(value: unknown): ShareDomain[] | null {
if (!Array.isArray(value)) return null;
for (const domain of value) {
if (typeof domain !== "string" || !DOMAINS.has(domain)) return null;
}
return [...(value as ShareDomain[])];
}
function hasRepeat(domains: readonly string[]): boolean {
return new Set(domains).size !== domains.length;
}
function isSubset(
inner: readonly string[],
outer: readonly string[] | null,
): boolean {
if (outer === null) return true;
const set = new Set(outer);
return inner.every((domain) => set.has(domain));
}
/**
* One entry, checked field by field and copied out with only the fields the
* contract names (unknown keys are dropped, as the schema strips them), or
* null. The invariants after the field checks are the schema's refinements.
*/
function parseEntry(value: unknown): AccountAccessEntry | null {
if (!isRecord(value)) return null;
const {
accountId,
username,
displayName,
fullName,
access,
level,
recordKind,
sections: rawSections,
canWrite,
writableDomains: rawWritable,
manageableDomains: rawManageable,
} = value;
if (!isNonEmptyString(accountId) || !isNonEmptyString(username)) return null;
if (!isNullableString(displayName) || !isNullableString(fullName)) {
return null;
}
if (access !== "read" && access !== "write") return null;
if (level !== "read" && level !== "write" && level !== "manage") return null;
if (recordKind !== "shared" && recordKind !== "managed") return null;
const sections = rawSections === null ? null : domainList(rawSections);
if (rawSections !== null && sections === null) return null;
if (typeof canWrite !== "boolean") return null;
const writableDomains = domainList(rawWritable);
const manageableDomains = domainList(rawManageable);
if (writableDomains === null || manageableDomains === null) return null;
const isManage = level === "manage";
if (sections !== null && hasRepeat(sections)) return null;
if (isManage && (access !== "write" || sections !== null)) return null;
if (canWrite !== (level !== "read")) return null;
if (!isManage && access !== level) return null;
if (hasRepeat(writableDomains) || hasRepeat(manageableDomains)) return null;
if (
!isSubset(writableDomains, sections) ||
!isSubset(manageableDomains, sections)
) {
return null;
}
if (!isSubset(manageableDomains, writableDomains)) return null;
if (level === "read" && writableDomains.length > 0) return null;
if (!isManage && manageableDomains.length > 0) return null;
return {
accountId,
username,
displayName,
fullName,
access,
level,
recordKind,
sections,
canWrite,
writableDomains,
manageableDomains,
};
}
/**
* The block's structure, without the cross-entry checks: what the schema
* accepts, and the same stripped value it returns. Exported for the
* equivalence test; the app calls {@link parseAccountAccess}.
*/
export function validateAccountAccessBlock(
value: unknown,
): AccountAccess | null {
if (!isRecord(value)) return null;
const {
accounts: rawAccounts,
active: rawActive,
recordKind,
canSwitch,
} = value;
if (!Array.isArray(rawAccounts)) return null;
const accounts: AccountAccessEntry[] = [];
for (const raw of rawAccounts) {
const entry = parseEntry(raw);
if (!entry) return null;
accounts.push(entry);
}
const active = rawActive === null ? null : parseEntry(rawActive);
if (rawActive !== null && active === null) return null;
if (
recordKind !== "self" &&
recordKind !== "shared" &&
recordKind !== "managed"
) {
return null;
}
if (typeof canSwitch !== "boolean") return null;
return { accounts, active, recordKind, canSwitch };
}
/** Both published views of the active record must name the same resolved grant. */
function sameEntry(a: AccountAccessEntry, b: AccountAccessEntry): boolean {
const sameList = (x: readonly string[], y: readonly string[]) =>
x.length === y.length && x.every((domain, index) => domain === y[index]);
const sameSections =
a.sections === null || b.sections === null
? a.sections === b.sections
: sameList(a.sections, b.sections);
return (
sameList(a.writableDomains, b.writableDomains) &&
sameList(a.manageableDomains, b.manageableDomains) &&
a.accountId === b.accountId &&
a.username === b.username &&
a.displayName === b.displayName &&
a.fullName === b.fullName &&
a.access === b.access &&
a.level === b.level &&
a.recordKind === b.recordKind &&
a.canWrite === b.canWrite &&
sameSections
);
}
/** Parse the server-resolved access block as a closed presentation contract. */
export function parseAccountAccess(value: unknown): AccountAccess | null {
const block = validateAccountAccessBlock(value);
if (!block) return null;
const { accounts, active, recordKind, canSwitch } = block;
const accountIds = new Set(accounts.map((entry) => entry.accountId));
if (
accountIds.size !== accounts.length ||
canSwitch !== accounts.length > 0
) {
return null;
}
if (active === null) {
return recordKind === "self"
? { accounts, active: null, recordKind, canSwitch }
: null;
}
if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) {
return null;
}
const canonicalActive = accounts.find(
(entry) => entry.accountId === active.accountId,
);
if (!canonicalActive || !sameEntry(active, canonicalActive)) return null;
return { accounts, active: canonicalActive, recordKind, canSwitch };
}
+7 -72
View File
@@ -1,6 +1,11 @@
/**
* The account-access block's contract as a Zod schema, for the OpenAPI
* document. The browser does not import it: its parse is the Zod-free
* `account-access-parse.ts`, kept out of the shell every route shares, and
* `account-access-parse.test.ts` holds the two to the same verdicts.
*/
import { z } from "zod/v4";
import type { AccountAccess } from "./account-access-view";
import { SHARE_DOMAINS } from "./scope";
const accountAccessLevelSchema = z.enum(["read", "write", "manage"]);
@@ -21,7 +26,7 @@ function isSubset(
return inner.every((domain) => set.has(domain));
}
/** The browser's resolved account-access payload, shared with OpenAPI. */
/** One account-access entry, as OpenAPI publishes it. */
export const accountAccessEntrySchema = z
.object({
accountId: z.string().min(1),
@@ -125,73 +130,3 @@ export const accountAccessBlockSchema = z.object({
recordKind: z.enum(["self", "shared", "managed"]),
canSwitch: z.boolean(),
});
/** Both published views of the active record must name the same resolved grant. */
function matchesCanonicalAccountAccessEntry(
active: z.infer<typeof accountAccessEntrySchema>,
canonical: z.infer<typeof accountAccessEntrySchema>,
): boolean {
const activeSections = active.sections;
const canonicalSections = canonical.sections;
const sameSections =
activeSections === null || canonicalSections === null
? activeSections === canonicalSections
: activeSections.length === canonicalSections.length &&
activeSections.every(
(section, index) => section === canonicalSections[index],
);
const sameList = (a: readonly string[], b: readonly string[]) =>
a.length === b.length && a.every((domain, index) => domain === b[index]);
return (
sameList(active.writableDomains, canonical.writableDomains) &&
sameList(active.manageableDomains, canonical.manageableDomains) &&
active.accountId === canonical.accountId &&
active.username === canonical.username &&
active.displayName === canonical.displayName &&
active.fullName === canonical.fullName &&
active.access === canonical.access &&
active.level === canonical.level &&
active.recordKind === canonical.recordKind &&
active.canWrite === canonical.canWrite &&
sameSections
);
}
/** Parse the server-resolved access block as a closed presentation contract. */
export function parseAccountAccess(value: unknown): AccountAccess | null {
const parsed = accountAccessBlockSchema.safeParse(value);
if (!parsed.success) return null;
const { accounts, active, recordKind, canSwitch } = parsed.data;
const accountIds = new Set(accounts.map((entry) => entry.accountId));
if (
accountIds.size !== accounts.length ||
canSwitch !== accounts.length > 0
) {
return null;
}
if (active === null) {
return recordKind === "self"
? { accounts, active: null, recordKind, canSwitch }
: null;
}
if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) {
return null;
}
const canonicalActive = accounts.find(
(entry) => entry.accountId === active.accountId,
);
if (
!canonicalActive ||
!matchesCanonicalAccountAccessEntry(active, canonicalActive)
) {
return null;
}
return { accounts, active: canonicalActive, recordKind, canSwitch };
}
+3 -152
View File
@@ -298,158 +298,9 @@ export const USER_CORRECTABLE_MEASUREMENT_SOURCES = [
EXTERNAL_SOURCE,
] as const;
const unitMap: Record<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
// v1.4.23 — sleep duration shifted from hours to minutes so HealthKit
// category-sample stages can be stored without precision loss. Older
// surfaces that need hours convert at read time (`minutes / 60`).
SLEEP_DURATION: "minutes",
ACTIVITY_STEPS: "steps",
BLOOD_GLUCOSE: "mg/dL",
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
// ── v1.4.23 Apple Health canonical units ──
HEART_RATE_VARIABILITY: "ms",
RESTING_HEART_RATE: "bpm",
ACTIVE_ENERGY_BURNED: "kcal",
FLIGHTS_CLIMBED: "flights",
WALKING_RUNNING_DISTANCE: "m",
VO2_MAX: "mL/(kg·min)",
BODY_TEMPERATURE: "celsius",
// ── v1.4.25 W5d Withings full coverage ──
FAT_FREE_MASS: "kg",
FAT_MASS: "kg",
MUSCLE_MASS: "kg",
// Distinct from BODY_TEMPERATURE — surface temps run ~32 °C; sharing
// the bucket would corrupt analytics. Same canonical unit (°C).
SKIN_TEMPERATURE: "celsius",
PULSE_WAVE_VELOCITY: "m/s",
VASCULAR_AGE: "years",
// Withings reports visceral fat as a 1–12 rating, not a percent. The
// string mirrors what Withings prints in Health Mate.
VISCERAL_FAT: "rating",
// ── v1.4.25 W8d Apple Health server-prep ──
// Sound-pressure level — A-weighted decibels (dBA). HealthKit reports
// both audio-exposure metrics in dBASPL; we store the unweighted "dBA"
// label because the A-weighting is implicit (every HealthKit audio
// sample carries it). 30 dBA = quiet bedroom; 140 dBA = pain threshold.
AUDIO_EXPOSURE_ENV: "dBA",
AUDIO_EXPOSURE_HEADPHONE: "dBA",
// Daily-rollup pattern (one sample = one day's outdoor-light minutes).
// 0–1440 covers the 24-hour day; in practice indoor users sit near 0
// and outdoor athletes accumulate a few hours.
TIME_IN_DAYLIGHT: "minutes",
// ── v1.5.5 iOS-coord additions ──
// Respiratory rate breaths-per-minute — the HK identifier ships as
// `count/min`; we keep the more conventional clinical label.
RESPIRATORY_RATE: "breaths/min",
// BMI kg/m² — HealthKit ships the unitless ratio; the canonical
// display string mirrors clinical convention.
BODY_MASS_INDEX: "kg/m²",
// Lean body mass kg — body-composition partner to FAT_MASS.
LEAN_BODY_MASS: "kg",
// Walking heart rate average bpm — daily rollup; distinct from
// RESTING_HEART_RATE (sleep-window minimum) and spot PULSE.
WALKING_HEART_RATE_AVERAGE: "bpm",
// Walking gait percent (0-100 after server-side ×100 scaling).
// Same convention as WALKING_STEADINESS / BODY_FAT / OXYGEN_SATURATION
// — see the project convention block in `apple-health-mapping.ts`.
WALKING_ASYMMETRY: "%",
WALKING_DOUBLE_SUPPORT: "%",
// ── v1.5.5 iOS-coord follow-up — raw-SI gait pair ──
// Step length is metres; speed is metres per second. Both flow
// raw on the wire — no server-side scaling. The unit strings
// match HealthKit's `m` / `m/s` defaults.
WALKING_STEP_LENGTH: "m",
WALKING_SPEED: "m/s",
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
// Cardio recovery is the bpm drop one minute after peak exercise.
CARDIO_RECOVERY: "bpm",
// Overnight wrist temperature in °C (absolute reading; Apple's own
// display frames it as a baseline deviation, we store the reading).
WRIST_TEMPERATURE: "celsius",
// Hard-fall detections — a plain count.
FALL_COUNT: "count",
// Apple's estimated six-minute-walk-test distance in metres.
SIX_MINUTE_WALK_DISTANCE: "m",
// Stair gait speeds — raw metres-per-second (no scaling).
STAIR_ASCENT_SPEED: "m/s",
STAIR_DESCENT_SPEED: "m/s",
// Per-night breathing-disturbance index — a unitless count Apple
// classifies as NotElevated / Elevated.
BREATHING_DISTURBANCES: "count",
// ── v1.10.0 — categorical events (WX-B) ──
// EVENT rows are dimensionless occurrences (value is always 1). The
// canonical unit is the bare "event" so any accidental numeric surfacing
// reads sensibly; the awareness timeline never displays the value.
IRREGULAR_RHYTHM_NOTIFICATION: "event",
HIGH_HEART_RATE_EVENT: "event",
LOW_HEART_RATE_EVENT: "event",
WALKING_STEADINESS_EVENT: "event",
BREATHING_DISTURBANCE_EVENT: "event",
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived 0–100 wellness scores. The canonical unit is the bare
// "score" so the value reads sensibly anywhere it surfaces.
RECOVERY_SCORE: "score",
STRESS_SCORE: "score",
STRAIN_SCORE: "score",
// ── v1.11.0 — WHOOP-native score classes ──
// RMSSD HRV is in milliseconds, same canonical unit as the SDNN
// HEART_RATE_VARIABILITY (different estimator, same dimension).
HRV_RMSSD: "ms",
// Day / workout strain ride WHOOP's bounded 0–21 scale; the bare "score"
// unit reads sensibly wherever the value surfaces (distinct from the
// 0–100 COMPUTED STRAIN_SCORE).
DAY_STRAIN: "score",
WORKOUT_STRAIN: "score",
// Sleep quality percentages (0–100).
SLEEP_PERFORMANCE: "%",
SLEEP_EFFICIENCY: "%",
SLEEP_CONSISTENCY: "%",
// Recommended sleep duration in minutes (WHOOP reports ms; mapper ÷60000).
SLEEP_NEED: "minutes",
// Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the
// device value round-trips rather than being converted to kcal).
ENERGY_EXPENDITURE_KJ: "kJ",
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
// Daily average / max heart rate bpm — whole-cycle aggregates, distinct
// from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE.
AVERAGE_HEART_RATE: "bpm",
MAX_HEART_RATE: "bpm",
// Per-night sleep disturbance tally — a plain integer count.
SLEEP_DISTURBANCE_COUNT: "count",
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro components ──
ANS_CHARGE: "score",
CARDIO_LOAD: "score",
// ── v1.17.1 — Oura coverage completion ──
// Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores.
SLEEP_SCORE: "score",
// Signed body-temperature deviation in °C (Oura nightly baseline offset).
BODY_TEMPERATURE_DEVIATION: "celsius",
// ── v1.19.0 — Oura resilience ──
// Ordinal level scale (1=limited … 5=exceptional) — the categorical band
// encoded into the numeric value. See RESILIENCE_LEVELS in src/lib/oura/client.
RESILIENCE: "level",
// ── v1.25 — clinical-signals wave ──
PHQ9_SCORE: "score",
GAD7_SCORE: "score",
// ── v1.27.9 — screening scores ──
WHO5_SCORE: "score",
SCI_SCORE: "score",
GRIP_STRENGTH: "kg",
PAIN_NRS: "score",
WAIST_CIRCUMFERENCE: "cm",
WAIST_TO_HEIGHT: "ratio",
};
export function getUnitForType(type: string): string {
return unitMap[type] ?? "unknown";
}
// The canonical unit per type lives in a Zod-free module so the client's
// display transform can read it without pulling Zod into every chart route.
export { getUnitForType } from "@/lib/measurements/unit-map";
// Plausible ranges per measurement type
export const VALUE_RANGES: Record<string, { min: number; max: number }> = {