mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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,
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user