chore(release): v1.4.30

Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked externalId shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class MoodEntry.note column that replaces the legacy tag-based workaround, a cross-source workout dedup helper, and two new MeasurementType enums plus the shared category overlay that drives the iOS permission picker and the future Insights nav.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-16 12:37:55 +02:00
1 parent 945b556664
commit 3ece95f192
49 files changed
+4744 -32

No files matched your search

@@ -0,0 +1,93 @@
/**
* `POST /api/admin/drain-per-sample-cumulative` — operator-triggered
* variant of `scripts/drain-per-sample-cumulative.ts`. Collapses
* pre-Option-A per-sample APPLE_HEALTH cumulative rows into one row
* per day per cumulative type. See `06-ios-responsibilities.md`
* §"Cumulative metrics: daily aggregation on iOS".
*
* Body:
* { userId?: string, dryRun?: boolean }
*
* - `userId` — single user mode. Omit to drain every user.
* - `dryRun` — preview-only; no DB writes. Default: `true`. Set to
* `false` to commit.
*
* Response shape (always 200 on success):
* { processed, inserted (always 0), totals: { ... }, buckets: [...] }
*
* Admin gate via `requireAdmin()` (cookie-only — Bearer never elevates
* to admin per the v1.4.25 security boundary).
*/
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
interface DrainBody {
userId?: string;
dryRun?: boolean;
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.drain.cumulative" } });
const { data: rawBody, error: jsonError } = await safeJson<DrainBody>(request);
if (jsonError) return jsonError;
const body: DrainBody = (rawBody && typeof rawBody === "object") ? rawBody : {};
const userId =
typeof body.userId === "string" && body.userId.length > 0
? body.userId
: undefined;
// Default to dryRun = true so a malformed body never silently writes.
// Operator must explicitly send `dryRun: false` to commit.
const dryRun = body.dryRun === false ? false : true;
await auditLog("admin.drain.cumulative.start", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: userId ?? "ALL", dryRun },
});
const lines: string[] = [];
const summary = await drainPerSampleCumulative(prisma, {
userId,
dryRun,
log: (line) => lines.push(line),
});
if (!dryRun) {
await auditLog("admin.drain.cumulative.commit", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
targetUserId: userId ?? "ALL",
...summary.totals,
},
});
}
if (summary.totals.bucketsCollapsed === 0 && summary.totals.usersScanned === 0 && userId) {
return apiError("User not found", 404);
}
return apiSuccess({
dryRun,
totals: summary.totals,
bucketCount: summary.buckets.length,
// Truncate to the first 50 buckets so the response stays
// bounded — operators inspect the CLI for the full output.
bucketsPreview: summary.buckets.slice(0, 50),
logTail: lines.slice(-20),
});
});
@@ -0,0 +1,215 @@
/**
* `POST /api/medications/intake/bulk` — iOS SyncMode bulk backfill
* for medication intake events.
*
* Mirrors the mood-entries bulk endpoint shape so the iOS sync
* engine reuses the same retry / cursor plumbing for both.
*
* Body:
* { entries: BulkIntakeEntry[] } — capped at 500 per call.
*
* Each entry:
* {
* medicationId, — required (existing Medication row)
* scheduledFor, — ISO timestamp (offset); defaults to now()
* takenAt?, — ISO timestamp; omit + skipped=false = pending
* skipped?, — boolean; default false
* idempotencyKey?, — Medication.intake_events.idempotency_key
* (existing UNIQUE column; serves as the
* per-entry dedup hint)
* }
*
* Response (always 200): processed / inserted / duplicates / skipped /
* entries[] mirroring the measurements + workouts batch envelope.
*
* Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md`
* §"Cumulative metrics" sibling section for the SyncMode rationale.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const bulkEntrySchema = z.object({
medicationId: z.string().min(1),
scheduledFor: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
skipped: z.boolean().optional().default(false),
idempotencyKey: z.string().min(1).max(128).optional(),
});
const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
});
type EntryStatus = "inserted" | "duplicate" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
id?: string;
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`medications:intake:bulk:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many bulk submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
if (
typeof rawBody === "object" &&
rawBody !== null &&
"entries" in rawBody &&
Array.isArray((rawBody as { entries: unknown }).entries) &&
(rawBody as { entries: unknown[] }).entries.length > MAX_ENTRIES_PER_BATCH
) {
return apiError(
`Batch exceeds the ${MAX_ENTRIES_PER_BATCH}-entry limit`,
422,
{ errorCode: "medications.intake.bulk.too_large" },
);
}
const parsed = bulkPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422, {
errorCode: "medications.intake.bulk.invalid",
});
}
const { entries } = parsed.data;
// Up-front ownership check — the user can only POST intake events
// against medications they own. One read covers every medicationId
// in the batch.
const medicationIds = Array.from(new Set(entries.map((e) => e.medicationId)));
const ownedMedications = await prisma.medication.findMany({
where: { id: { in: medicationIds }, userId: user.id },
select: { id: true },
});
const ownedSet = new Set(ownedMedications.map((m) => m.id));
const results: EntryResult[] = [];
let inserted = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!ownedSet.has(entry.medicationId)) {
const reason = "medication_not_found";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
try {
const scheduledFor = entry.scheduledFor ?? new Date();
// The idempotencyKey, when supplied, has a UNIQUE index. A
// re-submission of the same key returns the existing row via
// P2002 → status: "duplicate".
const row = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
source: "API",
idempotencyKey: entry.idempotencyKey ?? null,
},
});
inserted += 1;
results.push({ index: i, status: "inserted", id: row.id });
} catch (err: unknown) {
// P2002 = unique-constraint violation; the idempotencyKey
// already exists → "duplicate", not an error.
if (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: string }).code === "P2002"
) {
const existing = entry.idempotencyKey
? await prisma.medicationIntakeEvent.findUnique({
where: { idempotencyKey: entry.idempotencyKey },
select: { id: true },
})
: null;
duplicates += 1;
results.push({
index: i,
status: "duplicate",
id: existing?.id,
});
continue;
}
const reason =
err instanceof Error ? err.message.slice(0, 120) : "create_failed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
}
}
await auditLog("medications.intake.bulk.ingest", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
processed: entries.length,
inserted,
duplicates,
skipped: skipped.length,
},
});
annotate({
action: { name: "medications.intake.bulk.ingest" },
meta: {
processed: entries.length,
inserted,
duplicates,
skipped: skipped.length,
},
});
return apiSuccess({
processed: entries.length,
inserted,
duplicates,
skipped,
entries: results,
});
}
+3
View File
@@ -88,6 +88,9 @@ export const PUT = apiHandler(
if (data.tags !== undefined) {
updateData.tags = data.tags ? JSON.stringify(data.tags) : null;
}
if (data.note !== undefined) {
updateData.note = data.note;
}
const entry = await prisma.moodEntry.update({
where: { id },
+220
View File
@@ -0,0 +1,220 @@
/**
* `POST /api/mood-entries/bulk` — iOS SyncMode bulk backfill.
*
* When iOS pairs a fresh device with the server, it drains its local
* SwiftData mood log via this endpoint in one shot. Per-entry UPSERT
* semantics keyed by `externalId` so re-runs are idempotent.
*
* Body:
* { entries: BulkMoodEntry[] } — capped at 500 per call.
*
* Response (always 200):
* {
* processed,
* inserted,
* duplicates,
* skipped: [{ index, reason }, ...],
* entries: [{ index, status, id? }, ...]
* }
*
* Locked contract — see `.planning/v15-ios-handoff/06-ios-responsibilities.md`
* §"Cumulative metrics" sibling section for the SyncMode rationale,
* and `08-locked-contracts.md` §2 for the batch envelope shape.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import {
getScoreForMood,
moodLevelEnum,
moodSourceEnum,
} from "@/lib/validations/moodlog";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const bulkEntrySchema = z.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
note: z.string().max(500).optional(),
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
source: moodSourceEnum.optional().default("MANUAL"),
/**
* Optional iOS-side identifier (e.g. SwiftData row UUID) that lets
* the bulk endpoint dedup idempotently when iOS retries the same
* batch after a network hiccup. Mirrors the `externalId` posture on
* the measurements batch endpoint. NULL = no dedup hint; the
* existing `(userId, date, moodLoggedAt)` unique index still
* protects against straight-up duplicates.
*/
externalId: z.string().min(1).max(120).optional(),
});
const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
});
type EntryStatus = "inserted" | "duplicate" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
id?: string;
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk));
async function postBulk(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`mood-entries:bulk:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many bulk submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
if (
typeof rawBody === "object" &&
rawBody !== null &&
"entries" in rawBody &&
Array.isArray((rawBody as { entries: unknown }).entries) &&
(rawBody as { entries: unknown[] }).entries.length > MAX_ENTRIES_PER_BATCH
) {
return apiError(
`Batch exceeds the ${MAX_ENTRIES_PER_BATCH}-entry limit`,
422,
{ errorCode: "mood.bulk.too_large" },
);
}
const parsed = bulkPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422, {
errorCode: "mood.bulk.invalid",
});
}
const { entries } = parsed.data;
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const results: EntryResult[] = [];
let inserted = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const date = moodDateKey(entry.moodLoggedAt, tz);
const score = getScoreForMood(entry.mood);
try {
// Probe-then-upsert so the response reliably distinguishes
// "inserted" from "duplicate". Two round-trips per entry is
// acceptable given the 500-entry cap; a more cache-friendly
// shape (batched probe) is a v1.4.31 optimisation if the cap
// grows.
const existing = await prisma.moodEntry.findUnique({
where: {
userId_date_moodLoggedAt: {
userId: user.id,
date,
moodLoggedAt: entry.moodLoggedAt,
},
},
select: { id: true },
});
const result = await prisma.moodEntry.upsert({
where: {
userId_date_moodLoggedAt: {
userId: user.id,
date,
moodLoggedAt: entry.moodLoggedAt,
},
},
create: {
userId: user.id,
date,
tz,
mood: entry.mood,
score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: entry.note ?? null,
source: entry.source,
moodLoggedAt: entry.moodLoggedAt,
},
update: {
// Last-writer-wins on the mood + tags + note triple. The
// iOS client only re-posts an existing entry when it has
// new data; the server trusts that decision.
mood: entry.mood,
score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: entry.note ?? null,
},
});
if (existing) {
duplicates += 1;
results.push({ index: i, status: "duplicate", id: result.id });
} else {
inserted += 1;
results.push({ index: i, status: "inserted", id: result.id });
}
} catch (err: unknown) {
const reason =
err instanceof Error ? err.message.slice(0, 120) : "upsert_failed";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
}
}
await auditLog("mood.bulk.ingest", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
processed: entries.length,
inserted,
duplicates,
skipped: skipped.length,
},
});
annotate({
action: { name: "mood.bulk.ingest" },
meta: {
processed: entries.length,
inserted,
duplicates,
skipped: skipped.length,
},
});
return apiSuccess({
processed: entries.length,
inserted,
duplicates,
skipped,
entries: results,
});
}
+2 -1
View File
@@ -90,7 +90,7 @@ async function postMoodEntry(request: NextRequest) {
return apiError(parsed.error.issues[0].message, 422);
}
const { mood, tags, moodLoggedAt, source } = parsed.data;
const { mood, tags, note, moodLoggedAt, source } = parsed.data;
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
// current displayTimezone and store the resolved zone on the row.
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
@@ -108,6 +108,7 @@ async function postMoodEntry(request: NextRequest) {
mood,
score,
tags: tags ? JSON.stringify(tags) : null,
note: note ?? null,
source: source ?? "MANUAL",
moodLoggedAt,
},
+93
View File
@@ -0,0 +1,93 @@
/**
* `GET /api/sync/state` — iOS SyncMode handshake.
*
* The iOS SyncMode store reads this endpoint on pair / unpair, on
* background-to-foreground, and before opening a fresh anchored
* HealthKit query window. The response is a compact JSON shape iOS
* compares against its local SwiftData state to decide whether to
* (a) drain a pending outbox, (b) request a full refresh, or (c) sit
* quiet.
*
* Response shape:
* {
* userId,
* timezone,
* lastSyncedAt, — User.lastSyncedAt (ISO string or null)
* serverNow, — `new Date().toISOString()` for clock skew
* measurements: {
* lastUpdatedAt, — MAX(updated_at) for live rows
* liveCount, — COUNT(*) WHERE deleted_at IS NULL
* tombstonedCount, — COUNT(*) WHERE deleted_at IS NOT NULL
* }
* }
*
* No body. requireAuth() — cookie + Bearer both accepted (iOS uses
* Bearer per `05-auth-flows.md`).
*
* Locked contract: see `.planning/v15-ios-handoff/08-locked-contracts.md`
* §13 cross-reference. The shape is additive going forward.
*/
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
// Two aggregate reads + one user-side bump. The aggregate reads run
// against indexed columns (userId, deleted_at) so they stay
// O(rows-for-user) and not O(rows-across-tenants).
const [latest, liveCount, tombstonedCount, currentUser] = await Promise.all([
prisma.measurement.findFirst({
where: { userId: user.id, deletedAt: null },
orderBy: { updatedAt: "desc" },
select: { updatedAt: true },
}),
prisma.measurement.count({
where: { userId: user.id, deletedAt: null },
}),
prisma.measurement.count({
where: { userId: user.id, deletedAt: { not: null } },
}),
prisma.user.findUnique({
where: { id: user.id },
select: { lastSyncedAt: true, timezone: true },
}),
]);
// The handshake also bumps the user's lastSyncedAt — the call IS
// the handshake. iOS reads the OLD lastSyncedAt from the response
// and then trusts that subsequent server writes after the new
// checkpoint will round-trip via the standard read paths.
const previous = currentUser?.lastSyncedAt ?? null;
const nextCheckpoint = new Date();
await prisma.user.update({
where: { id: user.id },
data: { lastSyncedAt: nextCheckpoint },
});
annotate({
action: { name: "sync.state" },
meta: {
liveCount,
tombstonedCount,
lastSyncedAtBefore: previous?.toISOString() ?? null,
},
});
return apiSuccess({
userId: user.id,
timezone: currentUser?.timezone ?? "Europe/Berlin",
lastSyncedAt: previous?.toISOString() ?? null,
serverNow: nextCheckpoint.toISOString(),
measurements: {
lastUpdatedAt: latest?.updatedAt?.toISOString() ?? null,
liveCount,
tombstonedCount,
},
});
});
@@ -68,6 +68,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
AUDIO_EXPOSURE_ENV: "measurements.typeAudioExposureEnv",
AUDIO_EXPOSURE_HEADPHONE: "measurements.typeAudioExposureHeadphone",
TIME_IN_DAYLIGHT: "measurements.typeTimeInDaylight",
// ── v1.4.30 R-F T1.4 + T1.5 ──
WALKING_STEADINESS: "measurements.typeWalkingSteadiness",
AUDIO_EXPOSURE_EVENT: "measurements.typeAudioExposureEvent",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -110,6 +113,12 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
AUDIO_EXPOSURE_ENV: Volume2,
AUDIO_EXPOSURE_HEADPHONE: Headphones,
TIME_IN_DAYLIGHT: Sun,
// ── v1.4.30 R-F T1.4 + T1.5 ──
// Activity/Gauge carries the mobility-steadiness signal (same
// family as VO2_MAX). Volume2 is reused for the loud-listening
// event flag — the event is a louder-cousin of the env quantity.
WALKING_STEADINESS: Gauge,
AUDIO_EXPOSURE_EVENT: Volume2,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -149,4 +158,7 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
AUDIO_EXPOSURE_ENV: "bg-chart-5/20 text-chart-5",
AUDIO_EXPOSURE_HEADPHONE: "bg-chart-5/20 text-chart-5",
TIME_IN_DAYLIGHT: "bg-chart-2/20 text-chart-2",
// ── v1.4.30 R-F T1.4 + T1.5 ──
WALKING_STEADINESS: "bg-chart-2/20 text-chart-2",
AUDIO_EXPOSURE_EVENT: "bg-chart-5/20 text-chart-5",
};
@@ -46,10 +46,13 @@ const EXPECTED_TYPES = [
"AUDIO_EXPOSURE_ENV",
"AUDIO_EXPOSURE_HEADPHONE",
"TIME_IN_DAYLIGHT",
// ── v1.4.30 R-F T1.4 + T1.5 ──
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 28 canonical measurement types", () => {
it("exposes the 30 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -96,6 +99,12 @@ describe("measurementTypeEnum coverage", () => {
"AUDIO_EXPOSURE_ENV",
"AUDIO_EXPOSURE_HEADPHONE",
"TIME_IN_DAYLIGHT",
// v1.4.30 R-F T1.4 + T1.5 additions held under the same v1.5 gate.
// Walking-steadiness pairs with a mobility chip; audio-exposure
// events surface on the Insights audio sub-page. Neither belongs
// in the clinical PDF until the iOS-app sync lands.
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+5
View File
@@ -60,6 +60,11 @@ export const ALLOWED_CHART_TOKENS = [
"metric:AUDIO_EXPOSURE_ENV",
"metric:AUDIO_EXPOSURE_HEADPHONE",
"metric:TIME_IN_DAYLIGHT",
// v1.4.30 — R-F T1.4 + T1.5 Tier-1 additions. Same posture: the
// LLM can reference the tokens in prose; the chart components land
// alongside the iOS-app sync.
"metric:WALKING_STEADINESS",
"metric:AUDIO_EXPOSURE_EVENT",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
import {
APPLE_HEALTH_SLEEP_STAGE_MAP,
APPLE_HEALTH_TYPE_MAP,
CUMULATIVE_HK_TYPES,
HK_QUANTITY_TYPE_DEFERRED,
dailyStatsExternalId,
mapAppleHealthEntry,
} from "../apple-health-mapping";
import { measurementTypeEnum } from "@/lib/validations/measurement";
@@ -129,8 +131,8 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => {
// Cardiovascular / clinical
"HKQuantityTypeIdentifierAtrialFibrillationBurden",
"HKQuantityTypeIdentifierPeripheralPerfusionIndex",
// Mobility
"HKQuantityTypeIdentifierAppleWalkingSteadiness",
// Mobility — `AppleWalkingSteadiness` moved into the mapped
// set in v1.4.30. The remaining identifiers stay deferred.
"HKQuantityTypeIdentifierNumberOfTimesFallen",
"HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
// Respiratory / pulmonary
@@ -149,9 +151,9 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => {
"HKCategoryTypeIdentifierHighHeartRateEvent",
"HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
"HKCategoryTypeIdentifierLowCardioFitnessEvent",
// Audio-exposure events
"HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
"HKCategoryTypeIdentifierHeadphoneAudioExposureEvent",
// Audio-exposure events — environmental + headphone moved into
// the mapped set in v1.4.30 (AUDIO_EXPOSURE_EVENT). The general
// sound-reduction flag stays deferred.
"HKCategoryTypeIdentifierEnvironmentalSoundReduction",
// Behavioural / habit
"HKCategoryTypeIdentifierHandwashingEvent",
@@ -343,3 +345,99 @@ describe("mapAppleHealthEntry", () => {
expect(out).toBeNull();
});
});
describe("v1.4.30 Tier-1 additions (R-F T1.4 + T1.5)", () => {
it("maps appleWalkingSteadiness to WALKING_STEADINESS with × 100 scaling", () => {
const mapping =
APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierAppleWalkingSteadiness;
expect(mapping).toBeDefined();
expect(mapping.measurementType).toBe("WALKING_STEADINESS");
expect(mapping.convertToDbUnit(0.85)).toBeCloseTo(85);
expect(mapping.dbUnit).toBe("%");
});
it("maps environmental + headphone audio-exposure events to AUDIO_EXPOSURE_EVENT (count 1)", () => {
const envMapping =
APPLE_HEALTH_TYPE_MAP.HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent;
expect(envMapping).toBeDefined();
expect(envMapping.measurementType).toBe("AUDIO_EXPOSURE_EVENT");
expect(envMapping.convertToDbUnit(0)).toBe(1);
expect(envMapping.convertToDbUnit(99)).toBe(1);
const hpMapping =
APPLE_HEALTH_TYPE_MAP.HKCategoryTypeIdentifierHeadphoneAudioExposureEvent;
expect(hpMapping).toBeDefined();
expect(hpMapping.measurementType).toBe("AUDIO_EXPOSURE_EVENT");
expect(hpMapping.convertToDbUnit(0)).toBe(1);
});
it("does not double-book the new identifiers as deferred AND mapped", () => {
expect(
HK_QUANTITY_TYPE_DEFERRED.has(
"HKQuantityTypeIdentifierAppleWalkingSteadiness",
),
).toBe(false);
expect(
HK_QUANTITY_TYPE_DEFERRED.has(
"HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
),
).toBe(false);
expect(
HK_QUANTITY_TYPE_DEFERRED.has(
"HKCategoryTypeIdentifierHeadphoneAudioExposureEvent",
),
).toBe(false);
});
});
describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => {
it("produces the canonical stats:<type>:<date> shape for stepCount", () => {
expect(
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", "2026-05-16"),
).toBe("stats:HKQuantityTypeIdentifierStepCount:2026-05-16");
});
it("produces the same shape for every cumulative-type identifier", () => {
const cases: Array<[string, string]> = [
["HKQuantityTypeIdentifierStepCount", "2026-01-01"],
["HKQuantityTypeIdentifierActiveEnergyBurned", "2026-02-29"],
["HKQuantityTypeIdentifierFlightsClimbed", "2025-12-31"],
["HKQuantityTypeIdentifierDistanceWalkingRunning", "2024-02-29"],
["HKQuantityTypeIdentifierTimeInDaylight", "2026-05-16"],
];
for (const [id, day] of cases) {
expect(dailyStatsExternalId(id, day)).toBe(`stats:${id}:${day}`);
}
});
it("accepts the date string as-is — iOS owns the format", () => {
// Per R-A §5, iOS generates the date string from the user's IANA
// timezone via DateFormatter with the `yyyy-MM-dd` pattern. The
// server trusts the inbound shape rather than re-validating; the
// receiving Zod schema already caps `externalId` at 120 chars.
expect(
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", " 2026-05-16 "),
).toBe("stats:HKQuantityTypeIdentifierStepCount: 2026-05-16 ");
expect(
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", ""),
).toBe("stats:HKQuantityTypeIdentifierStepCount:");
});
it("covers every CUMULATIVE_HK_TYPES MeasurementType via a known HK identifier", () => {
// Sanity wall: every cumulative MeasurementType has a forward-map
// entry in APPLE_HEALTH_TYPE_MAP so the iOS-side daily-stats
// service can mint the externalId without a server round-trip.
const cumulativeIdentifiers = Object.values(APPLE_HEALTH_TYPE_MAP)
.filter((m) => CUMULATIVE_HK_TYPES.has(m.measurementType))
.map((m) => m.hkIdentifier);
expect(cumulativeIdentifiers.sort()).toEqual(
[
"HKQuantityTypeIdentifierStepCount",
"HKQuantityTypeIdentifierActiveEnergyBurned",
"HKQuantityTypeIdentifierFlightsClimbed",
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"HKQuantityTypeIdentifierTimeInDaylight",
].sort(),
);
});
});
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
MEASUREMENT_CATEGORIES,
getMeasurementCategory,
measurementTypesForCategory,
} from "../categories";
describe("MEASUREMENT_CATEGORIES — completeness wall", () => {
it("assigns a category to every MeasurementType in the canonical enum", () => {
const enumValues = measurementTypeEnum.options;
for (const type of enumValues) {
expect(
MEASUREMENT_CATEGORIES.has(type),
`MeasurementType ${type} has no category assignment`,
).toBe(true);
}
});
it("does not assign a category to a type the canonical enum does not know", () => {
const enumValues = new Set<string>(measurementTypeEnum.options);
for (const type of MEASUREMENT_CATEGORIES.keys()) {
expect(
enumValues.has(type),
`MeasurementType ${type} is mapped but not in the canonical enum`,
).toBe(true);
}
});
it("groups blood-pressure systolic and diastolic into the same category", () => {
expect(MEASUREMENT_CATEGORIES.get("BLOOD_PRESSURE_SYS")).toBe(
MEASUREMENT_CATEGORIES.get("BLOOD_PRESSURE_DIA"),
);
});
});
describe("getMeasurementCategory", () => {
it("returns the category for a known type", () => {
expect(getMeasurementCategory("WEIGHT")).toBe("body");
expect(getMeasurementCategory("BLOOD_GLUCOSE")).toBe("metabolic");
expect(getMeasurementCategory("ACTIVITY_STEPS")).toBe("activity");
expect(getMeasurementCategory("AUDIO_EXPOSURE_HEADPHONE")).toBe("hearing");
});
});
describe("measurementTypesForCategory", () => {
it("lists every type assigned to a category", () => {
expect(measurementTypesForCategory("hearing").sort()).toEqual([
"AUDIO_EXPOSURE_ENV",
"AUDIO_EXPOSURE_EVENT",
"AUDIO_EXPOSURE_HEADPHONE",
]);
});
it("returns an empty list for a category with no members today", () => {
// None of the current 27 enum values surface a mood-specific
// MeasurementType — mood lives in MoodEntry — so the category
// can be queried without surprise (mostly a sanity wall for the
// R-F §4.4 synthetic-token decision).
const allCategories: ReadonlyArray<string> = Array.from(
new Set(Array.from(MEASUREMENT_CATEGORIES.values())),
);
for (const cat of allCategories) {
const members = measurementTypesForCategory(cat as never);
expect(members.length, `${cat} should not be empty`).toBeGreaterThan(0);
}
});
it("includes the v1.4.25 W8d hearing + environment additions", () => {
// Regression sentinel — v1.4.25 W8d added AUDIO_EXPOSURE_ENV /
// AUDIO_EXPOSURE_HEADPHONE / TIME_IN_DAYLIGHT. The categorisation
// overlay must pick them up so the iOS picker surfaces them under
// Hearing + Environment rather than dropping them into the flat
// 27-line view that the overlay exists to avoid.
expect(measurementTypesForCategory("hearing")).toContain(
"AUDIO_EXPOSURE_ENV",
);
expect(measurementTypesForCategory("environment")).toContain(
"TIME_IN_DAYLIGHT",
);
});
});
@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import {
bucketRowsByUserDay,
canonicalDailyTimestamp,
dayKeyForUserTz,
sumBucketValues,
} from "../drain-per-sample-cumulative";
describe("dayKeyForUserTz", () => {
it("anchors the calendar day to the user's IANA zone", () => {
// 23:45 NZST on 2026-05-16 is still 11:45 UTC on the same day, but
// the user's calendar day in Europe/Berlin would be the previous day
// because Berlin is 10-11h behind NZST.
const instant = new Date("2026-05-16T11:45:00.000Z");
expect(dayKeyForUserTz(instant, "Pacific/Auckland")).toBe("2026-05-16");
// Europe/Berlin: same instant resolves to local 13:45 — still 2026-05-16.
expect(dayKeyForUserTz(instant, "Europe/Berlin")).toBe("2026-05-16");
// America/Los_Angeles (UTC-7 in DST): same instant resolves to 04:45 — 2026-05-16.
expect(dayKeyForUserTz(instant, "America/Los_Angeles")).toBe("2026-05-16");
});
it("uses sv-SE Intl formatting so output is ISO-shaped", () => {
const instant = new Date("2026-01-01T00:00:00.000Z");
expect(dayKeyForUserTz(instant, "UTC")).toBe("2026-01-01");
// The Pacific/Auckland clock is 13h ahead → already 2026-01-01 mid-afternoon at UTC midnight.
expect(dayKeyForUserTz(instant, "Pacific/Auckland")).toBe("2026-01-01");
// Europe/Berlin (UTC+1 in standard time): same instant → 01:00 local → still 2026-01-01.
expect(dayKeyForUserTz(instant, "Europe/Berlin")).toBe("2026-01-01");
});
});
describe("canonicalDailyTimestamp", () => {
it("anchors a Berlin day-key to the local-noon instant", () => {
// 12:00 local in Berlin during DST (CEST, UTC+2) is 10:00 UTC.
const ts = canonicalDailyTimestamp("2026-05-16", "Europe/Berlin");
expect(ts.toISOString()).toBe("2026-05-16T10:00:00.000Z");
});
it("anchors a UTC day-key to plain 12:00 UTC", () => {
const ts = canonicalDailyTimestamp("2026-05-16", "UTC");
expect(ts.toISOString()).toBe("2026-05-16T12:00:00.000Z");
});
it("anchors a Pacific/Auckland day-key to the local-noon instant", () => {
// 12:00 NZST (UTC+12) → 00:00 UTC on the same day.
const ts = canonicalDailyTimestamp("2026-05-16", "Pacific/Auckland");
expect(ts.toISOString()).toBe("2026-05-16T00:00:00.000Z");
});
it("handles half-hour-offset zones (India IST = UTC+5:30)", () => {
// 12:00 IST → 06:30 UTC on the same day.
const ts = canonicalDailyTimestamp("2026-05-16", "Asia/Kolkata");
expect(ts.toISOString()).toBe("2026-05-16T06:30:00.000Z");
});
});
describe("bucketRowsByUserDay", () => {
it("groups per-sample rows by the user's calendar day", () => {
const rows = [
{
id: "m-1",
type: "ACTIVITY_STEPS" as const,
value: 1200,
measuredAt: new Date("2026-05-16T08:00:00.000Z"), // 10:00 Berlin
externalId: "hk-uuid-1",
},
{
id: "m-2",
type: "ACTIVITY_STEPS" as const,
value: 3400,
measuredAt: new Date("2026-05-16T14:00:00.000Z"), // 16:00 Berlin
externalId: "hk-uuid-2",
},
{
id: "m-3",
type: "ACTIVITY_STEPS" as const,
value: 800,
measuredAt: new Date("2026-05-17T06:00:00.000Z"), // 08:00 Berlin, next day
externalId: "hk-uuid-3",
},
];
const { byDay } = bucketRowsByUserDay(rows, "Europe/Berlin");
expect(byDay.size).toBe(2);
expect(byDay.get("2026-05-16")?.length).toBe(2);
expect(byDay.get("2026-05-17")?.length).toBe(1);
});
it("skips rows whose externalId is already in stats:... shape (idempotent re-run)", () => {
const rows = [
{
id: "m-1",
type: "ACTIVITY_STEPS" as const,
value: 5000,
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
},
{
id: "m-2",
type: "ACTIVITY_STEPS" as const,
value: 1200,
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: "hk-uuid-pre-drain",
},
];
const { byDay } = bucketRowsByUserDay(rows, "Europe/Berlin");
// The already-collapsed row is skipped; only the per-sample row
// remains in the bucket.
expect(byDay.get("2026-05-16")?.length).toBe(1);
expect(byDay.get("2026-05-16")?.[0]?.id).toBe("m-2");
});
it("groups rows with NULL externalId (manual entries) like any other per-sample row", () => {
const rows = [
{
id: "m-1",
type: "ACTIVITY_STEPS" as const,
value: 1200,
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
externalId: null,
},
];
const { byDay } = bucketRowsByUserDay(rows, "Europe/Berlin");
expect(byDay.get("2026-05-16")?.length).toBe(1);
});
it("returns an empty map when the input is empty", () => {
const { byDay } = bucketRowsByUserDay([], "Europe/Berlin");
expect(byDay.size).toBe(0);
});
});
describe("sumBucketValues", () => {
it("sums a non-empty bucket", () => {
const rows = [
{ id: "1", type: "ACTIVITY_STEPS" as const, value: 1200, measuredAt: new Date(), externalId: null },
{ id: "2", type: "ACTIVITY_STEPS" as const, value: 3400, measuredAt: new Date(), externalId: null },
{ id: "3", type: "ACTIVITY_STEPS" as const, value: 800, measuredAt: new Date(), externalId: null },
];
expect(sumBucketValues(rows)).toBe(5400);
});
it("returns 0 for an empty bucket", () => {
expect(sumBucketValues([])).toBe(0);
});
it("handles fractional values (e.g. ACTIVE_ENERGY_BURNED kcal)", () => {
const rows = [
{ id: "1", type: "ACTIVE_ENERGY_BURNED" as const, value: 12.4, measuredAt: new Date(), externalId: null },
{ id: "2", type: "ACTIVE_ENERGY_BURNED" as const, value: 7.6, measuredAt: new Date(), externalId: null },
];
expect(sumBucketValues(rows)).toBeCloseTo(20.0);
});
});
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import { pickCanonicalWorkoutRows } from "../pick-canonical-workout-rows";
interface RowFixture {
id: string;
startedAt: Date;
sportType: string;
source: "APPLE_HEALTH" | "WITHINGS" | "MANUAL" | "IMPORT";
}
describe("pickCanonicalWorkoutRows", () => {
it("returns the empty list unchanged for an empty input", () => {
expect(pickCanonicalWorkoutRows([])).toEqual([]);
});
it("keeps a single workout regardless of source", () => {
const rows: RowFixture[] = [
{
id: "w-1",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "WITHINGS",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["w-1"]);
});
it("prefers APPLE_HEALTH over WITHINGS when both source the same workout", () => {
const rows: RowFixture[] = [
{
id: "apple",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "withings",
startedAt: new Date("2026-05-16T08:01:30Z"), // 90 s apart — same 5-min slot
sportType: "running",
source: "WITHINGS",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["apple"]);
});
it("does NOT collapse two distinct runs whose starts are > 5 min apart", () => {
const rows: RowFixture[] = [
{
id: "first",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "second",
startedAt: new Date("2026-05-16T08:06:00Z"), // 6 min after start — separate bucket
sportType: "running",
source: "APPLE_HEALTH",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual([
"first",
"second",
]);
});
it("keeps workouts of different sports that start in the same minute", () => {
const rows: RowFixture[] = [
{
id: "run",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "cycle",
startedAt: new Date("2026-05-16T08:01:00Z"),
sportType: "cycling",
source: "APPLE_HEALTH",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id).sort()).toEqual([
"cycle",
"run",
]);
});
it("preserves input order on the output", () => {
const rows: RowFixture[] = [
{
id: "morning",
startedAt: new Date("2026-05-16T06:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "evening",
startedAt: new Date("2026-05-16T18:00:00Z"),
sportType: "cycling",
source: "APPLE_HEALTH",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual([
"morning",
"evening",
]);
});
it("honours a user-supplied ladder that promotes WITHINGS over APPLE_HEALTH", () => {
const rows: RowFixture[] = [
{
id: "apple",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "withings",
startedAt: new Date("2026-05-16T08:01:00Z"),
sportType: "running",
source: "WITHINGS",
},
];
const userPriority = {
metricPriority: {
steps: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "IMPORT"],
},
};
expect(
pickCanonicalWorkoutRows(rows, userPriority).map((r) => r.id),
).toEqual(["withings"]);
});
it("keeps the manual entry when no priority-ladder source claims the slot", () => {
// Edge case: a user with both APPLE_HEALTH and WITHINGS still
// manually recorded an "elliptical" workout the integrations
// don't track. Manual wins by default (only entry in bucket).
const rows: RowFixture[] = [
{
id: "manual",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "elliptical",
source: "MANUAL",
},
];
expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["manual"]);
});
it("falls through to keeping every row when sources aren't on the ladder at all", () => {
// Theoretical safety net — no real source emits a value outside
// the canonical enum, but the picker MUST NEVER drop signal it
// can't classify. Forcing a ladder of a single source the bucket
// doesn't carry exercises the fallback path.
const rows: RowFixture[] = [
{
id: "a",
startedAt: new Date("2026-05-16T08:00:00Z"),
sportType: "running",
source: "APPLE_HEALTH",
},
{
id: "b",
startedAt: new Date("2026-05-16T08:01:00Z"),
sportType: "running",
source: "WITHINGS",
},
];
const oddPriority = {
metricPriority: {
steps: ["IMPORT"],
},
};
expect(
pickCanonicalWorkoutRows(rows, oddPriority)
.map((r) => r.id)
.sort(),
).toEqual(["a", "b"]);
});
});
+101 -9
View File
@@ -285,6 +285,43 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
convertToDbUnit: (v) => v,
aggregation: "sum",
},
// ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ────────────────
// Walking steadiness — iOS 15+ Mobility daily rollup. Apple ships
// a 0..1 fraction; HealthLog stores 0..100 percent (same convention
// as oxygen saturation + body fat).
HKQuantityTypeIdentifierAppleWalkingSteadiness: {
hkIdentifier: "HKQuantityTypeIdentifierAppleWalkingSteadiness",
measurementType: "WALKING_STEADINESS",
hkUnit: "%",
dbUnit: "%",
// Apple ships 0..1 fraction; HealthLog stores 0..100.
convertToDbUnit: (v) => v * 100,
aggregation: "latest",
},
// Environmental audio-exposure event — iOS 13+ category-type that
// fires when the rolling 7-day average crosses the WHO 80-dBA loud-
// listening threshold. Stored as a 1.0 count per fired event; the
// `notes` field carries the source token ("env" vs "headphone").
HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent: {
hkIdentifier: "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
measurementType: "AUDIO_EXPOSURE_EVENT",
hkUnit: "count",
dbUnit: "count",
convertToDbUnit: () => 1,
aggregation: "sum",
},
// Headphone audio-exposure event — same shape as the environmental
// sibling; both share the AUDIO_EXPOSURE_EVENT MeasurementType so
// chart-card consumers can pick them up uniformly.
HKCategoryTypeIdentifierHeadphoneAudioExposureEvent: {
hkIdentifier: "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent",
measurementType: "AUDIO_EXPOSURE_EVENT",
hkUnit: "count",
dbUnit: "count",
convertToDbUnit: () => 1,
aggregation: "sum",
},
};
/**
@@ -310,6 +347,62 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet<MeasurementType> = new Set<Measure
"TIME_IN_DAYLIGHT",
]);
/**
* v1.4.30 — externalId shape for daily-aggregated cumulative
* HealthKit rows. iOS emits one row per day per cumulative type via
* `HKStatisticsCollectionQuery` per R-A Option A; the externalId
* UPSERTs the matching server row idempotently across re-syncs.
*
* Format: `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>`.
*
* Cumulative HK types only — the spot-sample path keeps using
* `HKSample.uuid` as `externalId`. The shape is intentionally
* stable across the cutover: iOS clients still posting per-sample
* rows round-trip through the existing `(userId, type, source,
* externalId)` unique index; iOS clients on the daily-stats path
* collide on the deterministic `"stats:..."` key for idempotent
* UPSERTs.
*
* Locked contract — see
* `.planning/v15-ios-handoff/08-locked-contracts.md` §13 and
* `.planning/v15-ios-handoff/06-ios-responsibilities.md` Domain 1
* "Cumulative metrics: daily aggregation on iOS".
*
* The helper accepts the date string as-is: iOS generates it from
* the user's IANA timezone via `DateFormatter` with the
* `yyyy-MM-dd` pattern; the server trusts that format rather than
* re-validating per ingest because the iOS handoff doc locks the
* shape and the receiving Zod schema already caps `externalId` at
* 120 characters.
*/
export function dailyStatsExternalId(
hkIdentifier: string,
dateYYYYMMDD: string,
): string {
return `stats:${hkIdentifier}:${dateYYYYMMDD}`;
}
/**
* v1.4.30 — reverse lookup from a HealthLog `MeasurementType` to the
* canonical HealthKit identifier for that type. Used by the drain
* script when minting a `dailyStatsExternalId` from a row whose
* `hkIdentifier` is not carried on the table (the per-sample ingest
* stores only the resolved `MeasurementType`).
*
* Returns `null` when the type has no HealthKit counterpart (Withings-
* only metrics). Callers in the cumulative-drain path can assume the
* lookup succeeds because `CUMULATIVE_HK_TYPES` is a subset of the
* HK-mapped types.
*/
export function hkIdentifierForType(
type: MeasurementType,
): string | null {
for (const mapping of Object.values(APPLE_HEALTH_TYPE_MAP)) {
if (mapping.measurementType === type) return mapping.hkIdentifier;
}
return null;
}
/**
* HK identifiers the iOS app may emit that HealthLog deliberately does
* NOT map yet. Listing them here means the batch route can log a
@@ -411,9 +504,9 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
// opt-in than the existing Health-share prompt.
"HKQuantityTypeIdentifierAtrialFibrillationBurden",
"HKQuantityTypeIdentifierPeripheralPerfusionIndex",
// Mobility (iOS 15+) — surface as a wellness signal in v1.5 once the
// Insights cardio sub-page has room for a steadiness gauge.
"HKQuantityTypeIdentifierAppleWalkingSteadiness",
// Mobility (iOS 15+) — `AppleWalkingSteadiness` moved into the
// mapping table in v1.4.30 (R-F T1.5). The remaining identifiers
// stay deferred until a wellness sub-page surface lands.
"HKQuantityTypeIdentifierNumberOfTimesFallen",
"HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
// Respiratory / pulmonary clinical (iOS 17) — pair with FHIR clinical
@@ -435,12 +528,11 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set<string>([
"HKCategoryTypeIdentifierHighHeartRateEvent",
"HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
"HKCategoryTypeIdentifierLowCardioFitnessEvent",
// Audio-exposure events (iOS 13+) — the continuous AUDIO_EXPOSURE_*
// quantity identifiers ARE mapped above; these are the
// "loud-event-fired" flags that pair with them. Defer until we
// surface event chips in the Insights audio sub-page.
"HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent",
"HKCategoryTypeIdentifierHeadphoneAudioExposureEvent",
// Audio-exposure events (iOS 13+) — Environmental + Headphone
// moved into the mapping table in v1.4.30 (R-F T1.4) as
// AUDIO_EXPOSURE_EVENT. The general "sound reduction" flag stays
// deferred until we surface event chips in the Insights audio
// sub-page.
"HKCategoryTypeIdentifierEnvironmentalSoundReduction",
// Behavioural / habit category-types — not in HealthLog scope yet.
"HKCategoryTypeIdentifierHandwashingEvent",
+134
View File
@@ -0,0 +1,134 @@
/**
* v1.4.30 — UI-side categorisation overlay for `MeasurementType`.
*
* The flat 27-value enum is the correct storage shape (per R-F §4.1)
* — Postgres enums are cheap; a per-category sub-table would explode
* the read joins. The overlay below is presentation-only and drives:
*
* - The iOS HealthKit permission picker — grouped by `category` so
* the user sees one consent screen per logical area (vitals,
* activity, sleep, …) rather than a flat 27-line list. The iOS
* side mirrors this map verbatim via the swift-openapi-generator
* codegen path (R-E H-8); a new MeasurementType is one change in
* this file + the Prisma enum + the Zod validator + this overlay.
*
* - The web Insights nav (post-v1.5) — the sub-page strip stays
* curated at 7 pages, but the "All metrics" overview can group
* cards by category cheaply from this map.
*
* - The Coach evidence shelf — chip-grouping by category makes the
* prose more navigable than a flat list.
*
* Locked: see `.planning/research/v15-r-f-apple-health-depth.md` §4.
*/
import type { MeasurementType } from "@/generated/prisma/client";
/**
* Apple-Health-flavoured category set. Collapses Apple's
* Heart + Vitals into one `vitals` bucket and omits the ones we
* deliberately don't track (Cycle, Nutrition, Respiratory clinical,
* Symptoms).
*
* `cardiovascular` is distinct from `vitals` — vitals are point-in-
* time signals (BP, pulse, HRV, body temp, SpO2); cardiovascular
* holds derived risk markers that arrive less frequently (pulse-wave
* velocity, vascular age).
*/
export type MeasurementCategory =
| "vitals"
| "body"
| "activity"
| "sleep"
| "hearing"
| "environment"
| "cardiovascular"
| "metabolic";
/**
* Per-MeasurementType → category mapping. The map is exhaustive over
* the current enum surface — adding a new MeasurementType is a
* compile-time error in this file until a category is assigned.
*
* The map is intentionally `ReadonlyMap` rather than a plain object
* literal so consumers cannot mutate it accidentally; a flat
* `Record<MeasurementType, MeasurementCategory>` would be equally
* valid but provides no immutability guard.
*/
export const MEASUREMENT_CATEGORIES: ReadonlyMap<
MeasurementType,
MeasurementCategory
> = new Map<MeasurementType, MeasurementCategory>([
// ── Vitals (point-in-time signals) ──
["BLOOD_PRESSURE_SYS", "vitals"],
["BLOOD_PRESSURE_DIA", "vitals"],
["PULSE", "vitals"],
["RESTING_HEART_RATE", "vitals"],
["HEART_RATE_VARIABILITY", "vitals"],
["OXYGEN_SATURATION", "vitals"],
["BODY_TEMPERATURE", "vitals"],
// ── Body composition ──
["WEIGHT", "body"],
["BODY_FAT", "body"],
["FAT_FREE_MASS", "body"],
["FAT_MASS", "body"],
["MUSCLE_MASS", "body"],
["TOTAL_BODY_WATER", "body"],
["BONE_MASS", "body"],
["VISCERAL_FAT", "body"],
// ── Activity (cumulative + fitness + mobility) ──
["ACTIVITY_STEPS", "activity"],
["ACTIVE_ENERGY_BURNED", "activity"],
["FLIGHTS_CLIMBED", "activity"],
["WALKING_RUNNING_DISTANCE", "activity"],
["VO2_MAX", "activity"],
["WALKING_STEADINESS", "activity"],
// ── Sleep ──
["SLEEP_DURATION", "sleep"],
// ── Hearing (audio exposure quantities + events) ──
["AUDIO_EXPOSURE_ENV", "hearing"],
["AUDIO_EXPOSURE_HEADPHONE", "hearing"],
["AUDIO_EXPOSURE_EVENT", "hearing"],
// ── Environment ──
["TIME_IN_DAYLIGHT", "environment"],
// ── Cardiovascular (derived risk markers) ──
["PULSE_WAVE_VELOCITY", "cardiovascular"],
["VASCULAR_AGE", "cardiovascular"],
// ── Metabolic ──
["BLOOD_GLUCOSE", "metabolic"],
["SKIN_TEMPERATURE", "metabolic"],
]);
/**
* Lookup the category for a `MeasurementType`. Returns `undefined`
* only if a new enum value lands in the schema without a sibling
* entry in the map — the completeness test in
* `__tests__/categories.test.ts` is the regression sentinel.
*/
export function getMeasurementCategory(
type: MeasurementType,
): MeasurementCategory | undefined {
return MEASUREMENT_CATEGORIES.get(type);
}
/**
* List every `MeasurementType` belonging to a given category. Useful
* for the iOS picker fan-out and the web Insights nav grouping. The
* return order is the insertion order in `MEASUREMENT_CATEGORIES`
* above — kept deterministic so the picker UX has a stable layout.
*/
export function measurementTypesForCategory(
category: MeasurementCategory,
): MeasurementType[] {
const out: MeasurementType[] = [];
for (const [type, cat] of MEASUREMENT_CATEGORIES) {
if (cat === category) out.push(type);
}
return out;
}
@@ -0,0 +1,344 @@
/**
* v1.4.30 — drain pre-Option-A per-sample APPLE_HEALTH cumulative rows
* into one row per day per type, keyed by the locked `dailyStatsExternalId`
* shape. Idempotent: re-running collapses zero rows once every cumulative
* bucket already holds a single `stats:...` row.
*
* Scope per `CUMULATIVE_HK_TYPES`:
* ACTIVITY_STEPS, ACTIVE_ENERGY_BURNED, FLIGHTS_CLIMBED,
* WALKING_RUNNING_DISTANCE, TIME_IN_DAYLIGHT
*
* Per user × type × calendar day (anchored to `User.timezone`):
* 1. SELECT all `Measurement` rows with `source = 'APPLE_HEALTH'` and
* `type = <cumulative type>` and `measuredAt` within that user's
* calendar day boundary and `externalId NOT LIKE 'stats:%'`.
* 2. If 0 rows → continue.
* 3. If 1 row whose externalId already follows the `stats:...`
* shape → continue (already collapsed).
* 4. SUM the values; pick canonical timestamp = midday UTC of the
* user's calendar day (matches the Withings activity-sync
* convention per R-A §5 / W17b).
* 5. UPSERT a row with `externalId = dailyStatsExternalId(...)`,
* `value = sumValue`, `measuredAt = canonicalTimestamp`.
* 6. DELETE the original per-sample rows in the same transaction.
*
* Designed to be invoked by both:
* - the CLI at `scripts/drain-per-sample-cumulative.ts`
* - the admin endpoint at
* `POST /api/admin/drain-per-sample-cumulative`
*
* The dry-run path emits the same per-user-day summary without
* touching the DB so the operator can inspect what would change before
* committing.
*/
import { Prisma } from "@/generated/prisma/client";
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import {
CUMULATIVE_HK_TYPES,
dailyStatsExternalId,
hkIdentifierForType,
} from "./apple-health-mapping";
/** Per-(user, type, day) action summary. */
export interface DrainBucket {
userId: string;
type: MeasurementType;
/** Calendar-day key in the user's timezone (`YYYY-MM-DD`). */
dateKey: string;
/** Number of per-sample rows scanned for this bucket. */
perSampleCount: number;
/** SUM of per-sample values for this bucket (canonical-unit). */
sumValue: number;
/** ISO-8601 canonical timestamp (midday UTC of the user's calendar day). */
canonicalTimestamp: string;
/** Resulting `externalId` of the daily-aggregated row. */
externalId: string;
}
export interface DrainSummary {
/** Did the run actually write to the DB (false for `dryRun`). */
dryRun: boolean;
/** Per-user-day buckets the drain rewrote (or would rewrite). */
buckets: DrainBucket[];
/** Aggregate counts across the run. */
totals: {
usersScanned: number;
bucketsCollapsed: number;
perSampleRowsDeleted: number;
dailyRowsUpserted: number;
};
}
export interface DrainOptions {
/** Limit the drain to a single user. Default = every user. */
userId?: string;
/** Preview-only mode — no DB writes, no transaction commits. */
dryRun?: boolean;
/** Logger sink — defaults to `console.log`. */
log?: (line: string) => void;
}
/**
* Resolve the user's calendar-day key (`YYYY-MM-DD`) for a given
* timestamp + timezone. Reuses the same `sv-SE` Intl formatting choice
* the mood-entries path locked in v1.4.25 W7b so iOS-side and
* server-side day-keys round-trip byte-identically.
*/
export function dayKeyForUserTz(date: Date, tz: string): string {
return new Intl.DateTimeFormat("sv-SE", { timeZone: tz }).format(date);
}
/**
* Compute the canonical timestamp for a calendar-day key in the user's
* timezone. Returns the JS-Date instant at the user's local 12:00 noon
* — matches the Withings activity sync convention (one daily row per
* type, anchored to midday so the row sorts cleanly between same-day
* spot samples). The string returned by `toISOString()` is UTC.
*/
export function canonicalDailyTimestamp(
dateKey: string,
tz: string,
): Date {
// Compute the UTC offset for noon-local of the given day. We don't
// have a lightweight TZ-math library on the server, so the trick is:
// build "12:00 UTC of the day", read what wall-clock that shows in
// the target zone, then shift by the resulting offset.
const utcNoon = new Date(`${dateKey}T12:00:00.000Z`);
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hour12: false,
hour: "2-digit",
minute: "2-digit",
timeZoneName: "shortOffset",
});
// The shortOffset entry looks like "GMT+2", "GMT-5:30", or "GMT".
const parts = fmt.formatToParts(utcNoon);
const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT";
const match = tzPart.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
if (!match) {
// UTC — noon UTC is noon local.
return utcNoon;
}
const sign = match[1] === "+" ? 1 : -1;
const hours = Number.parseInt(match[2], 10);
const minutes = match[3] ? Number.parseInt(match[3], 10) : 0;
const offsetMinutes = sign * (hours * 60 + minutes);
// utcNoon represents 12:00 UTC. The user's local clock reads
// 12:00 + offsetMinutes at that instant. To anchor at local 12:00,
// subtract the offset.
return new Date(utcNoon.getTime() - offsetMinutes * 60 * 1000);
}
/**
* Group an array of per-sample rows into per-day buckets keyed by the
* user's timezone. Exposed for unit testing the bucketing semantics
* without booting Prisma.
*/
export interface PerSampleRow {
id: string;
type: MeasurementType;
value: number;
measuredAt: Date;
externalId: string | null;
}
export interface BucketedRows {
/** Map keyed by `YYYY-MM-DD` (user TZ). */
byDay: Map<string, PerSampleRow[]>;
}
export function bucketRowsByUserDay(
rows: readonly PerSampleRow[],
tz: string,
): BucketedRows {
const byDay = new Map<string, PerSampleRow[]>();
for (const row of rows) {
// Skip rows already in the daily-stats shape — re-running the
// drain on a previously-collapsed bucket is a no-op.
if (row.externalId !== null && row.externalId.startsWith("stats:")) {
continue;
}
const key = dayKeyForUserTz(row.measuredAt, tz);
const slot = byDay.get(key) ?? [];
slot.push(row);
byDay.set(key, slot);
}
return { byDay };
}
/**
* Sum the values in a per-day bucket. Tiny helper; keeps the call
* site (transactional drain loop) readable.
*/
export function sumBucketValues(rows: readonly PerSampleRow[]): number {
let acc = 0;
for (const row of rows) acc += row.value;
return acc;
}
/**
* Run the drain. Idempotent — re-invocation after a successful drain
* reports zero buckets collapsed.
*
* The function does NOT enforce the admin gate or the operator-confirm
* flag — the CLI wrapper + the admin endpoint own those concerns.
*/
export async function drainPerSampleCumulative(
prismaClient: PrismaClient,
options: DrainOptions = {},
): Promise<DrainSummary> {
const dryRun = options.dryRun ?? false;
const log = options.log ?? ((line) => console.log(line));
const users = options.userId
? await prismaClient.user.findMany({
where: { id: options.userId },
select: { id: true, timezone: true },
})
: await prismaClient.user.findMany({
select: { id: true, timezone: true },
});
const summary: DrainSummary = {
dryRun,
buckets: [],
totals: {
usersScanned: users.length,
bucketsCollapsed: 0,
perSampleRowsDeleted: 0,
dailyRowsUpserted: 0,
},
};
for (const user of users) {
const tz = user.timezone && user.timezone.length > 0 ? user.timezone : "Europe/Berlin";
log(`[drain] user=${user.id} tz=${tz}${dryRun ? " (dry-run)" : ""}`);
for (const type of CUMULATIVE_HK_TYPES) {
const hkIdentifier = hkIdentifierForType(type);
if (!hkIdentifier) continue;
const perSampleRows = (await prismaClient.measurement.findMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
type,
},
select: {
id: true,
type: true,
value: true,
measuredAt: true,
externalId: true,
},
orderBy: { measuredAt: "asc" },
})) as PerSampleRow[];
if (perSampleRows.length === 0) continue;
const { byDay } = bucketRowsByUserDay(perSampleRows, tz);
for (const [dateKey, dayRows] of byDay) {
if (dayRows.length === 0) continue;
// Already-collapsed buckets are caught above (rows with
// externalId starting "stats:" never enter the bucket map).
// A single per-sample row left over is still drained — that
// way the externalId converges to the canonical shape even
// when iOS happened to emit exactly one sample on a quiet
// day.
const sumValue = sumBucketValues(dayRows);
const canonicalTs = canonicalDailyTimestamp(dateKey, tz);
const externalId = dailyStatsExternalId(hkIdentifier, dateKey);
const bucket: DrainBucket = {
userId: user.id,
type,
dateKey,
perSampleCount: dayRows.length,
sumValue,
canonicalTimestamp: canonicalTs.toISOString(),
externalId,
};
summary.buckets.push(bucket);
summary.totals.bucketsCollapsed += 1;
if (!dryRun) {
await prismaClient.$transaction(async (tx) => {
// Upsert the daily-aggregated row first, then drop the
// per-sample rows. The unique index
// (userId, type, source, externalId) makes the upsert
// idempotent across re-runs.
await tx.measurement.upsert({
where: {
userId_type_source_externalId: {
userId: user.id,
type,
source: "APPLE_HEALTH",
externalId,
},
},
create: {
userId: user.id,
type,
value: sumValue,
unit: dayRows[0]?.value !== undefined
? // pick the canonical unit from the existing row by
// looking it up via the mapping table; the per-sample
// rows all carry the same unit on a given type, so
// any of them would do.
await resolveCanonicalUnit(tx, user.id, type)
: "count",
source: "APPLE_HEALTH",
measuredAt: canonicalTs,
externalId,
},
update: {
value: sumValue,
measuredAt: canonicalTs,
},
});
// Delete the per-sample rows that contributed to the sum.
// Using `id IN (...)` is bounded by the per-bucket cap
// (the largest cumulative bucket in real data is a
// ~1 440-row stepCount day on a phone-only user).
const ids = dayRows.map((r) => r.id);
const del = await tx.measurement.deleteMany({
where: { id: { in: ids } },
});
summary.totals.perSampleRowsDeleted += del.count;
summary.totals.dailyRowsUpserted += 1;
});
} else {
summary.totals.perSampleRowsDeleted += dayRows.length;
summary.totals.dailyRowsUpserted += 1;
}
}
}
}
log(
`[drain] done — usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted}${dryRun ? " (dry-run)" : ""}`,
);
return summary;
}
/**
* Pull the canonical unit for a `(userId, type)` pair from an existing
* per-sample row. Used during the upsert's `create` branch when we
* need to populate `Measurement.unit` for the new aggregated row.
*/
async function resolveCanonicalUnit(
tx: Prisma.TransactionClient,
userId: string,
type: MeasurementType,
): Promise<string> {
const row = await tx.measurement.findFirst({
where: { userId, type, source: "APPLE_HEALTH" },
select: { unit: true },
});
return row?.unit ?? "count";
}
@@ -0,0 +1,157 @@
/**
* v1.4.30 — cross-source canonical-row picker for `Workout` rows.
*
* Same intent as `pickCanonicalSourceRows()` in
* `src/lib/analytics/source-priority.ts`, applied to workouts. A user
* wearing an Apple Watch + a Withings ScanWatch records the same run
* once via each integration; the dashboard "Recent workouts" tile
* would otherwise show the run twice.
*
* Algorithm (v1.4.30 — matches the measurement ladder per R-F open
* question #4):
* 1. Bucket workouts by the rounded start timestamp + sport-type
* (`<5-minute slot>:<sport>`). Two HKWorkout entries for the same
* logical run start within a minute of each other on both
* sources; the rounding gives the cross-source matcher slack
* without collapsing distinct back-to-back workouts.
* 2. For each bucket, walk the SOURCE priority ladder
* (`APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT` per the existing
* source-priority validator's default) and pick the FIRST
* source that has any row in that bucket. Keep every row from
* the winning source; drop the rest.
* 3. Rows whose source isn't in the ladder pass through untouched
* (fallback intent — never silently drop signal).
*
* Determinism precondition: input rows MUST be in deterministic order
* (typically `ORDER BY startedAt ASC, id ASC` from the caller's
* Prisma read). The output preserves input order.
*
* Future v1.5.x tune: metric-aware picker per R-F open question #4 —
* route → Apple wins, HR zones → Withings wins. Out of scope for
* v1.4.30; the existing ladder ships first so the dashboard tile is
* unambiguous.
*/
import type {
MeasurementSource,
} from "@/generated/prisma/client";
import {
getSourceLadder,
parseSourcePriority,
} from "@/lib/validations/source-priority";
/**
* Minimum row shape — same posture as `SourcePickerRow`. The helper
* only consults the three fields; downstream callers can read every
* other column they fetched alongside.
*/
export interface WorkoutPickerRow {
startedAt: Date;
sportType: string;
source: MeasurementSource;
}
/**
* Bucket-slot length. Two workouts whose `startedAt` falls in the
* same 5-minute slot AND share the same sportType are treated as the
* "same" workout from different sources. Tightens further than the
* 1-minute clock skew between HK + Withings would suggest because
* iOS posts HK `startDate` rounded to the second but Withings stamps
* its `startdate` independently — five minutes gives both heads
* enough slack on a heavy-traffic morning sync without crossing the
* "two back-to-back runs" boundary (a user does NOT do two ten-minute
* runs five minutes apart in practice).
*/
const BUCKET_SLOT_MS = 5 * 60 * 1000;
function bucketKey(row: WorkoutPickerRow): string {
const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS);
return `${slot}:${row.sportType}`;
}
/**
* Pick canonical rows across sources for a user's workouts.
*
* @param rows input rows (deterministic order required)
* @param userPriorityJson the user's `sourcePriorityJson` blob — same
* shape the measurement picker consults.
* Falls back to the canonical default ladder
* when null / unparseable.
* @returns the subset of input rows that survives the picker. Insertion
* order preserved.
*/
export function pickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
rows: readonly T[],
userPriorityJson: unknown = null,
): T[] {
if (rows.length === 0) return [];
// Workouts ride the `steps` metric's source ladder — the
// canonical-activity bucket. The user's iOS Settings → Sources UI
// configures the same ladder for the workout dashboard tile;
// unifying on "steps" keeps a single configuration surface rather
// than introducing a new `workouts` metric key. Future ladders for
// workout-specific tunes (route-vs-zones) ride a v1.5.x change.
const resolved = parseSourcePriority(userPriorityJson);
const sourceLadder = getSourceLadder(resolved, "steps");
const buckets = new Map<
string,
{
rows: T[];
sources: Set<MeasurementSource>;
}
>();
for (const row of rows) {
const key = bucketKey(row);
const slot = buckets.get(key) ?? { rows: [], sources: new Set() };
slot.rows.push(row);
slot.sources.add(row.source);
buckets.set(key, slot);
}
const canonical: T[] = [];
for (const [, slot] of buckets) {
if (slot.rows.length === 1) {
// No cross-source overlap → keep the single row regardless of
// ladder membership.
canonical.push(slot.rows[0]);
continue;
}
let picked: MeasurementSource | undefined;
for (const source of sourceLadder) {
if (slot.sources.has(source)) {
picked = source;
break;
}
}
if (!picked) {
// None of the bucket's sources are listed in the ladder — never
// silently drop. Keep every row in the bucket; downstream
// callers can choose to surface a "duplicate from unrecognised
// sources" diagnostic.
canonical.push(...slot.rows);
continue;
}
for (const row of slot.rows) {
if (row.source === picked) canonical.push(row);
}
}
// Re-sort to preserve input order — the bucket map iterates in
// insertion order but the per-bucket fan-out can interleave rows
// from later buckets ahead of earlier-bucket survivors. Caller
// wants the same `startedAt ASC` order it passed in.
canonical.sort((a, b) => {
const delta = a.startedAt.getTime() - b.startedAt.getTime();
if (delta !== 0) return delta;
// Stable-tiebreak on sportType so the output is deterministic
// even when two surviving rows share startedAt (rare; manual
// entries on the same second).
return a.sportType.localeCompare(b.sportType);
});
return canonical;
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { extractNoteAndSlimTags } from "../note-backfill";
describe("extractNoteAndSlimTags", () => {
it("extracts the first note:<text> entry and slims the tag array", () => {
expect(
extractNoteAndSlimTags(JSON.stringify(["coffee", "note:slept poorly", "workout"])),
).toEqual({
note: "slept poorly",
newTags: JSON.stringify(["coffee", "workout"]),
});
});
it("returns null tags when the slimmed array is empty", () => {
expect(extractNoteAndSlimTags(JSON.stringify(["note:lonely entry"]))).toEqual({
note: "lonely entry",
newTags: null,
});
});
it("returns note=null when no entry starts with note:", () => {
expect(extractNoteAndSlimTags(JSON.stringify(["coffee", "workout"]))).toEqual({
note: null,
newTags: JSON.stringify(["coffee", "workout"]),
});
});
it("extracts only the first note:... entry; further note-prefixed entries stay as tags", () => {
expect(
extractNoteAndSlimTags(JSON.stringify(["note:first", "note:second"])),
).toEqual({
note: "first",
newTags: JSON.stringify(["note:second"]),
});
});
it("returns the input string when it is not valid JSON", () => {
expect(extractNoteAndSlimTags("not-a-json-array")).toEqual({
note: null,
newTags: "not-a-json-array",
});
});
it("returns the input string when the JSON is not an array", () => {
expect(extractNoteAndSlimTags(JSON.stringify({ note: "x" }))).toEqual({
note: null,
newTags: JSON.stringify({ note: "x" }),
});
});
it("preserves the note prefix text exactly (no trim, no decode)", () => {
expect(
extractNoteAndSlimTags(JSON.stringify(["note: with leading space "])),
).toEqual({
note: " with leading space ",
newTags: null,
});
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* v1.4.30 — pure helper for the `MoodEntry.note` backfill (R-E H-5).
*
* Pulls the first `note:<text>` entry out of a JSON-encoded tags array
* and returns the cleaned tags JSON alongside the extracted note. Used
* by `scripts/backfill-mood-note-column.ts` (CLI) — kept in `src/lib/`
* so unit tests can exercise the bucketing without booting `dotenv`
* + the Prisma client.
*/
export function extractNoteAndSlimTags(tagsJson: string): {
note: string | null;
newTags: string | null;
} {
let parsed: unknown;
try {
parsed = JSON.parse(tagsJson);
} catch {
return { note: null, newTags: tagsJson };
}
if (!Array.isArray(parsed)) {
return { note: null, newTags: tagsJson };
}
let extracted: string | null = null;
const remaining: string[] = [];
for (const tag of parsed) {
if (typeof tag !== "string") {
remaining.push(String(tag));
continue;
}
if (extracted === null && tag.startsWith("note:")) {
extracted = tag.slice("note:".length);
} else {
remaining.push(tag);
}
}
return {
note: extracted,
newTags: remaining.length > 0 ? JSON.stringify(remaining) : null,
};
}
+9
View File
@@ -55,6 +55,10 @@ export function getPRDirection(
case "BONE_MASS":
case "MUSCLE_MASS":
case "TIME_IN_DAYLIGHT":
// v1.4.30 — walking steadiness on a 0-100 scale; Apple's own
// Mobility section treats higher as the achievement (the
// recovery direction from a low-steadiness window).
case "WALKING_STEADINESS":
return PersonalRecordDirection.MAX;
// MIN direction — lower value is the record.
@@ -79,6 +83,11 @@ export function getPRDirection(
case "WEIGHT":
case "SLEEP_DURATION":
case "FAT_FREE_MASS":
// v1.4.30 — audio-exposure events fire on a threshold-cross, not
// on a goal axis. A "record" for "fewest loud-listening days in
// a row" is interesting but better surfaced as a streak than a
// PR direction. Defer.
case "AUDIO_EXPOSURE_EVENT":
return null;
}
}
+3
View File
@@ -32,6 +32,9 @@ export const measurementTypeEnum = z.enum([
"AUDIO_EXPOSURE_ENV",
"AUDIO_EXPOSURE_HEADPHONE",
"TIME_IN_DAYLIGHT",
// ── v1.4.30 R-F T1.4 + T1.5 ──
"WALKING_STEADINESS",
"AUDIO_EXPOSURE_EVENT",
]);
export const glucoseContextEnum = z.enum([
+5
View File
@@ -81,6 +81,10 @@ export function getScoreForMood(mood: string): number {
export const createMoodEntrySchema = z.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
// v1.4.30 H-5 — first-class free-text note. Replaces the
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
note: z.string().max(500).optional(),
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
source: moodSourceEnum.optional().default("MANUAL"),
});
@@ -88,6 +92,7 @@ export const createMoodEntrySchema = z.object({
export const updateMoodEntrySchema = z.object({
mood: moodLevelEnum.optional(),
tags: z.array(z.string().max(50)).max(20).nullable().optional(),
note: z.string().max(500).nullable().optional(),
moodLoggedAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))