Merge branch 'main' into feat/encounter-phase-1

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 12:22:04 +02:00
commit a83be074ca
65 files changed
+7864 -624

No files matched your search

+70 -3
View File
@@ -1,6 +1,7 @@
/**
* Pair guard binding `prisma/schema.prisma`'s `MoodEntry` and
* `MoodEntryTagLink` models to the disaster-recovery backup selects.
* Pair guard binding `prisma/schema.prisma`'s `MoodEntry`,
* `MoodEntryTagLink` and `MoodContext` models to the disaster-recovery backup
* selects.
*
* The sibling of `measurement-backup-completeness.test.ts`, written as its own
* file rather than as a second describe inside it, because that guard's own
@@ -27,7 +28,8 @@
* this goes red naming that column; add a scalar to the model without adding
* it to the select and it goes red naming the new one; rename the mapper's
* `moodEntry` parameter and the serialization assertion goes red on all of
* them at once.
* them at once. The same three hold for `MOOD_CONTEXT_BACKUP_SELECT` and the
* `moodContext` mapper parameter beside it.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -52,6 +54,16 @@ const EXCLUDED_MOOD_ENTRY_COLUMNS: Record<string, string> = {
userId: "user-scoped backup; owner is re-derived from the restoring account",
};
const EXCLUDED_MOOD_CONTEXT_COLUMNS: Record<string, string> = {
moodEntryId:
"re-bound to the restored parent row's id; the context rides inside its entry",
userId: "user-scoped backup; owner is re-derived from the restoring account",
createdAt:
"the sub-row's own bookkeeping stamp; the entry carries the moment the person logged the day, and the context has nothing separate to say about when it was typed",
updatedAt:
"the sub-row's own bookkeeping stamp, moved by every write; a restore re-stamps it and nothing reads the old value",
};
const EXCLUDED_TAG_LINK_COLUMNS: Record<string, string> = {
moodEntryId: "re-bound to the restored parent row's id, never carried",
moodTagId:
@@ -173,6 +185,11 @@ describe("mood backup completeness", () => {
models,
);
const linkSelected = selectFields(source, "MOOD_ENTRY_TAG_LINK_SELECT");
const contextColumns = scalarFields(
modelBlock(schema, "MoodContext"),
models,
);
const contextSelected = selectFields(source, "MOOD_CONTEXT_BACKUP_SELECT");
it("parses both ends into a plausible shape", () => {
expect(models.size).toBeGreaterThan(50);
@@ -194,6 +211,47 @@ describe("mood backup completeness", () => {
expect(linkColumns).not.toContain("moodEntry");
expect(linkColumns).not.toContain("moodTag");
expect(linkSelected.length).toBeGreaterThan(0);
expect(contextColumns.length).toBeGreaterThan(15);
expect(contextColumns).toContain("notesEncrypted");
expect(contextColumns).toContain("eventAt");
expect(contextColumns).not.toContain("moodEntry");
expect(contextColumns).not.toContain("user");
expect(contextSelected.length).toBeGreaterThan(15);
});
it("the backup select covers every MoodContext column", () => {
const missing = contextColumns.filter(
(c) =>
!contextSelected.includes(c) && !(c in EXCLUDED_MOOD_CONTEXT_COLUMNS),
);
if (missing.length > 0) {
const report = missing.map((c) => ` ❌ ${c}`).join("\n");
throw new Error(
`MOOD_CONTEXT_BACKUP_SELECT omits ${missing.length} column(s) of the MoodContext model:\n${report}\n\n` +
"A disaster-recovery restore brings these back as NULL, which reads " +
"as an answer nobody gave rather than as a lost one. Add each to " +
"the select and to both serialization arms, extend " +
"`moodContextSchema` in the backup validations and the restore " +
"write — or add it to EXCLUDED_MOOD_CONTEXT_COLUMNS with the reason " +
"the backup is correct to drop it.",
);
}
});
it("every selected MoodContext column reaches a serialized payload", () => {
const emitted = serialisedFields(source, "moodContext");
const dropped = contextSelected.filter((c) => !emitted.has(c));
if (dropped.length > 0) {
const report = dropped.map((c) => ` ❌ ${c}`).join("\n");
throw new Error(
`${dropped.length} selected MoodContext column(s) reach neither serialization arm:\n${report}\n\n` +
"The query pays for them and no payload carries them, so a restore " +
"cannot put them back.",
);
}
});
it("the backup select covers every MoodEntry column", () => {
@@ -290,5 +348,14 @@ describe("mood backup completeness", () => {
).toContain(column);
expect(reason.length).toBeGreaterThan(20);
}
for (const [column, reason] of Object.entries(
EXCLUDED_MOOD_CONTEXT_COLUMNS,
)) {
expect(
contextColumns,
`${column} is excluded but is no longer a MoodContext column`,
).toContain(column);
expect(reason.length).toBeGreaterThan(20);
}
});
});
+6 -1
View File
@@ -887,6 +887,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "mind",
why: "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.",
},
"app/api/mood/linked-context/route.ts": {
domain: "record",
why: "What the sleep, activity, vitals and illness modules already hold for one of the record's days, read-only. It is reached from a mood surface and it is NOT a mind read: the payload is sleep minutes, steps, active energy, resting heart rate, heart-rate variability and an illness day-log with its symptom keys. Declared `mind` it let a mood-only delegate walk arbitrary dates and read two sections the owner had declined to share, with no entry needing to exist for the date. Admitted on the whole-record grant only, like the other cross-section reads; a scoped grant is refused rather than served a filtered day.",
},
"app/api/mood-entries/[id]/route.ts": {
domain: "mind",
why: "One mood entry of the record, fetch-then-guard against the resolved user.",
@@ -1856,8 +1860,9 @@ const ACTOR_ROUTES: Record<string, string> = {
* managed-record configuration adds one more: 196 → 197. The bounded
* read-only profile summary makes the next explicit admission: 197 → 198.
* The two initial Cycle reads follow as separate, scoped admissions: 198 → 200.
* The mood day's linked figures, read-only, add one: 200 → 201.
*/
const FROZEN_ENTRY_COUNT = 212;
const FROZEN_ENTRY_COUNT = 213;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -862,6 +862,50 @@ const handler = apiHandler(
update: restoredData,
});
// The day context, rebuilt after the entry so it can bind to
// whatever id that entry actually got. Delete-then-write rather
// than an upsert with a preserve-when-absent arm: the file
// states the whole context or states that there was none, and a
// restored entry carrying half a file's context and half a live
// row's would be a state neither of them describes.
await tx.moodContext.deleteMany({
where: { moodEntryId: restored.id },
});
if (entry.context) {
const context = entry.context;
await tx.moodContext.create({
data: {
moodEntryId: restored.id,
userId: ownerId,
workStatus: context.workStatus ?? null,
workMinutes: context.workMinutes ?? null,
overtimeMinutes: context.overtimeMinutes ?? null,
workLoad: context.workLoad ?? null,
workSatisfaction: context.workSatisfaction ?? null,
contactCircles: context.contactCircles ?? null,
contactForm: context.contactForm ?? null,
contactExtent: context.contactExtent ?? null,
contactQuality: context.contactQuality ?? null,
contactSupport: context.contactSupport ?? null,
leisureCategories: context.leisureCategories ?? null,
leisureMinutes: context.leisureMinutes ?? null,
leisureJoy: context.leisureJoy ?? null,
leisureRecovery: context.leisureRecovery ?? null,
eventType: context.eventType ?? null,
eventValence: context.eventValence ?? null,
eventAt: context.eventAt ? new Date(context.eventAt) : null,
// The note comes back the way every other dual-column
// note does: ciphertext verbatim when the file carries
// it, a portable file's plain text re-encrypted on the
// way in.
notesEncrypted:
context.notesEncrypted == null
? encryptNote(context.note ?? null)
: decodeEncryptedBytes(context.notesEncrypted),
},
});
}
await tx.moodEntryTagLink.deleteMany({
where: { moodEntryId: restored.id },
});
@@ -11,6 +11,16 @@ const txClient = vi.hoisted(() => ({
moodEntryTagLink: {
findMany: vi.fn(),
},
// v1.38 — the day context is replaced inside the same transaction. Answering
// null from `findUnique` keeps this fake honest: what a context write
// actually stores is proven against real Postgres in
// `tests/integration/mood-context.test.ts`, not by a stub that agrees with
// whatever it is asked.
moodContext: {
deleteMany: vi.fn(),
upsert: vi.fn(),
findUnique: vi.fn(),
},
}));
vi.mock("@/lib/db", () => ({
@@ -114,6 +124,8 @@ beforeEach(() => {
);
txClient.moodEntry.update.mockResolvedValue(UPDATED_ENTRY as never);
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
txClient.moodContext.deleteMany.mockResolvedValue({ count: 0 });
txClient.moodContext.findUnique.mockResolvedValue(null);
vi.mocked(replaceTagLinks).mockResolvedValue(undefined);
vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined);
// v1.7.0 sync — PUT now looks the row up via `findFirst` with a
@@ -296,6 +308,11 @@ describe("PUT /api/mood-entries/[id] — split tag replacement", () => {
moodEntryTagLink: {
findMany: vi.fn(),
},
moodContext: {
deleteMany: vi.fn(),
upsert: vi.fn(),
findUnique: vi.fn(),
},
};
vi.mocked(replaceTagLinks).mockImplementationOnce(async () => {
draft.binary = ["calm"];
+36 -5
View File
@@ -16,6 +16,7 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { deriveA1, shapeLevelA } from "@/lib/mood/level-a";
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
@@ -47,6 +48,9 @@ export const GET = apiHandler(
// (not `findUnique`) because `deletedAt` is not part of a unique index.
const entry = await prisma.moodEntry.findFirst({
where: { id, deletedAt: null },
// v1.38 — the day context rides with the entry rather than behind a
// second request: the edit surface needs both or neither.
include: { context: true },
});
if (!entry || entry.userId !== user.id) {
@@ -58,10 +62,13 @@ export const GET = apiHandler(
meta: { moodEntryId: id },
});
const { context, ...row } = entry;
return apiSuccess({
// v1.37 — level-A values under the keys the write path takes.
...shapeLevelA(shapeMoodNote(entry)),
tags: parseTags(entry.tags),
...shapeLevelA(shapeMoodNote(row)),
tags: parseTags(row.tags),
// v1.38 — lists decoded, note decrypted, ciphertext dropped.
context: context ? contextForWire(context) : null,
});
},
);
@@ -201,6 +208,20 @@ export const PUT = apiHandler(
);
}
// v1.38 — the day context replaces whole when the request carried
// one, and is left alone when it did not, matching the two link sets
// above. Inside the same transaction: a context write that fails rolls
// the edit and its syncVersion increment back with it.
const contextOutcome = await persistMoodContext(
tx,
id,
user.id,
data.context,
);
const storedContext = await tx.moodContext.findUnique({
where: { moodEntryId: id },
});
// Return the same split shape as list/create: binary keys are
// independent from rated-factor keys and their per-entry scores.
const links = await tx.moodEntryTagLink.findMany({
@@ -213,6 +234,8 @@ export const PUT = apiHandler(
return {
entry: updated,
contextOutcome,
persistedContext: storedContext,
persistedTagKeys: links
.filter((link) => link.moodTag.kind !== "RATED")
.map((link) => link.moodTag.key),
@@ -252,8 +275,13 @@ export const PUT = apiHandler(
}
throw error;
}
const { entry, persistedTagKeys, persistedRatedFactors } =
transactionOutcome.result;
const {
entry,
persistedTagKeys,
persistedRatedFactors,
persistedContext,
contextOutcome,
} = transactionOutcome.result;
await auditLog("moodEntry.update", {
userId: user.id,
@@ -278,6 +306,7 @@ export const PUT = apiHandler(
...(data.tags !== undefined ? ["tags"] : []),
...(data.tagKeys !== undefined ? ["tagKeys"] : []),
...(data.ratedFactors !== undefined ? ["ratedFactors"] : []),
...(data.context !== undefined ? ["context"] : []),
],
}),
},
@@ -285,7 +314,7 @@ export const PUT = apiHandler(
annotate({
action: { name: "mood-entries.update" },
meta: { moodEntryId: id },
meta: { moodEntryId: id, mood_context: contextOutcome },
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
@@ -328,6 +357,8 @@ export const PUT = apiHandler(
// refetch (shape-matches the list GET).
tagKeys: persistedTagKeys,
ratedFactors: persistedRatedFactors,
// v1.38 — the stored context after the edit, or null when there is none.
context: persistedContext ? contextForWire(persistedContext) : null,
});
},
);
@@ -0,0 +1,92 @@
/**
* The bulk path's per-entry dimension bound, pinned to the single-entry one.
*
* The bulk route validates `a1`..`a5` per entry rather than in the batch
* schema, because the endpoint's contract is that a bad row is skipped and the
* rest of the batch lands. That split buys the tolerance and costs a second
* copy of "0 to 10", and a second copy of a bound is a bound that drifts: the
* day somebody widens the scale on one path, the other silently keeps
* refusing values the product now accepts, and it does it 500 rows at a time.
*
* So the two are compared here by behaviour rather than by reading each
* other's source — the same value is offered to both schemas and they have to
* agree on it, at both ends of the scale and one step outside each.
*
* Mutation check: change the bulk route's `.max(10)` to `.max(9)` and the
* boundary row goes red naming 10; change the single-entry `levelADimension`
* bound instead and the same row goes red from the other side.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createMoodEntrySchema } from "@/lib/validations/mood";
const ROOT = process.cwd();
const BULK_ROUTE = join(
ROOT,
"src",
"app",
"api",
"mood-entries",
"bulk",
"route.ts",
);
/**
* The bulk schema is module-private, so it is read as source and its bound is
* extracted. A matcher that found nothing would prove nothing, which is why
* the extraction asserts its own match count before anything is compared.
*/
function bulkDimensionBound(): { min: number; max: number } {
const source = readFileSync(BULK_ROUTE, "utf8");
const start = source.indexOf("const bulkDimensionsSchema = z.object({");
expect(
start,
"bulkDimensionsSchema not found — the per-entry parse was renamed and this guard now proves nothing",
).toBeGreaterThan(-1);
const body = source.slice(start, source.indexOf("\n});", start));
const matches = [
...body.matchAll(/\.int\(\)\.min\((-?\d+)\)\.max\((\d+)\)/g),
];
expect(
matches.length,
"no bounded dimension fields found inside bulkDimensionsSchema",
).toBe(5);
const bounds = new Set(matches.map((m) => `${m[1]}..${m[2]}`));
expect(
[...bounds],
"the five bulk dimensions do not share one bound",
).toHaveLength(1);
return { min: Number(matches[0][1]), max: Number(matches[0][2]) };
}
/** Does the single-entry create schema accept this `a1`? */
function singleEntryAccepts(a1: number): boolean {
return createMoodEntrySchema.safeParse({
mood: "OKAY",
moodLoggedAt: "2026-05-01T08:00:00.000Z",
a1,
}).success;
}
describe("bulk dimension bounds agree with the single-entry schema", () => {
const bulk = bulkDimensionBound();
it("reads a plausible bound out of the bulk route", () => {
expect(bulk.max).toBeGreaterThan(bulk.min);
expect(Number.isInteger(bulk.min)).toBe(true);
expect(Number.isInteger(bulk.max)).toBe(true);
});
it("accepts the same values at both ends of the scale", () => {
expect(singleEntryAccepts(bulk.min)).toBe(true);
expect(singleEntryAccepts(bulk.max)).toBe(true);
});
it("refuses the same values one step outside it", () => {
expect(singleEntryAccepts(bulk.min - 1)).toBe(false);
expect(singleEntryAccepts(bulk.max + 1)).toBe(false);
});
});
@@ -19,6 +19,16 @@ const txClient = {
moodEntryTagLink: {
findMany: vi.fn().mockResolvedValue([]),
},
// v1.38 — the day context is written inside the same transaction, and the
// route reads it back for the response. A fake that answered a row here
// regardless of what was written would prove nothing, so `findUnique`
// answers null and the context-carrying claims live in the integration
// suite (`tests/integration/mood-context.test.ts`) against real Postgres.
moodContext: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
upsert: vi.fn(),
findUnique: vi.fn().mockResolvedValue(null),
},
};
vi.mock("@/lib/db", () => ({
+104 -8
View File
@@ -41,6 +41,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { markSyncCheckpoint } from "@/lib/sync/checkpoint";
import {
getScoreForMood,
moodContextSchema,
moodLevelEnum,
moodSourceEnum,
} from "@/lib/validations/mood";
@@ -51,7 +52,8 @@ import {
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { deriveA1 } from "@/lib/mood/level-a";
import { levelAForWrite } from "@/lib/mood/level-a";
import { persistMoodContext } from "@/lib/mood/context";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
@@ -94,6 +96,39 @@ const bulkEntrySchema = z.object({
.max(30)
.optional(),
note: z.string().max(500).optional(),
/**
* v1.38 — the five level-A self-state values, each 0-10, each optional.
* A batch that carries none still writes complete rows: the server derives
* `a1` from the five-point label, so an older client build needs no change
* to stay correct.
*
* `unknown` here and parsed with {@link bulkDimensionsSchema} PER ENTRY in
* the loop below, for the same reason `context` is: the doc comment beside
* this field used to promise that an out-of-range value marks THIS entry
* skipped while the field itself sat in the batch schema and 422'd all five
* hundred. The words were right and the code was not. A phone draining a
* year of local history must not lose the year to one row.
* @per-entry-parse: bulkDimensionsSchema, in the loop below
*/
a1: z.unknown().optional(),
a2: z.unknown().optional(),
a3: z.unknown().optional(),
a4: z.unknown().optional(),
a5: z.unknown().optional(),
/**
* v1.38 — the day's context. Optional, and absent leaves a stored context
* alone on a re-post, matching the single-entry path: a build with no
* context surface must not blank one filled in on the web.
*
* Declared as `unknown` here and parsed with `moodContextSchema` PER ENTRY
* in the loop below, deliberately. Validating it in the batch schema would
* make one entry carrying a retired key fail all five hundred, and this
* endpoint's whole contract — stated in its own OpenAPI description — is
* that a bad row is `skipped` and the rest of the batch lands. A phone
* draining a year of local history must not lose the year to one row.
* @per-entry-parse: moodContextSchema, in the loop below
*/
context: z.unknown().optional(),
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
source: moodSourceEnum.optional().default("MANUAL"),
/**
@@ -111,6 +146,23 @@ const bulkEntrySchema = z.object({
externalId: z.string().min(1).max(120).optional(),
});
/**
* The five dimensions, validated for one entry.
*
* The bound is the scale itself, matching the single-entry schema exactly —
* one definition of "0 to 10" would be better still, but the single-entry
* schema states it as a shared const and this one has to answer per entry, so
* the two are pinned to each other by
* `src/app/api/mood-entries/__tests__/bulk-dimension-bounds.test.ts` instead.
*/
const bulkDimensionsSchema = z.object({
a1: z.number().int().min(0).max(10).nullable().optional(),
a2: z.number().int().min(0).max(10).nullable().optional(),
a3: z.number().int().min(0).max(10).nullable().optional(),
a4: z.number().int().min(0).max(10).nullable().optional(),
a5: z.number().int().min(0).max(10).nullable().optional(),
});
const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
});
@@ -275,13 +327,53 @@ async function postBulk(request: NextRequest): Promise<Response> {
continue;
}
// The context, parsed for THIS entry. A value outside the vocabulary
// skips this row and nothing else.
const parsedContext =
entry.context === undefined
? { success: true as const, data: undefined }
: moodContextSchema.nullable().safeParse(entry.context);
if (!parsedContext.success) {
const reason = "invalid_context";
skipped.push({ index: i, reason });
results.push({
index: i,
status: "skipped",
reason,
...(entry.externalId ? { externalId: entry.externalId } : {}),
});
continue;
}
// The five dimensions, parsed for THIS entry. Out of range skips this row
// and nothing else.
const parsedDimensions = bulkDimensionsSchema.safeParse({
a1: entry.a1,
a2: entry.a2,
a3: entry.a3,
a4: entry.a4,
a5: entry.a5,
});
if (!parsedDimensions.success) {
const reason = "invalid_dimensions";
skipped.push({ index: i, reason });
results.push({
index: i,
status: "skipped",
reason,
...(entry.externalId ? { externalId: entry.externalId } : {}),
});
continue;
}
const date = moodDateKey(entry.moodLoggedAt, tz);
const score = getScoreForMood(entry.mood);
// v1.37 — pleasantness derived from the same label the score comes from.
// The batch request shape is unchanged: this path carries no level-A
// input, so A2 to A5 are left alone rather than written as nulls, and a
// row that already carries hand-set values keeps them across a re-import.
const moodA1 = deriveA1(entry.mood);
// v1.37 / v1.38 — the same resolution the single-entry path uses:
// pleasantness derived from the label unless the entry states its own, and
// the other four present only when this entry carried them. An omitted
// dimension is left alone rather than written as null, so a row that
// already carries hand-set values keeps them across a re-import.
const levelA = levelAForWrite(entry.mood, parsedDimensions.data);
try {
// v1.12.1 — when the entry carries a source-stable `externalId`,
@@ -353,7 +445,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
tz,
mood: entry.mood,
score,
moodA1,
...levelA,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: null,
noteEncrypted: encryptNote(entry.note ?? null),
@@ -370,7 +462,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
// the same row.
mood: entry.mood,
score,
moodA1,
...levelA,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: null,
noteEncrypted: encryptNote(entry.note ?? null),
@@ -392,6 +484,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
// transaction that throw now rolls the mood row back with it, so a
// "skipped" result means nothing was written — which is what the
// client was already being told.
// v1.38 — the day context, inside the same transaction as the row, so
// an entry reported as skipped really did write nothing.
await persistMoodContext(tx, upserted.id, user.id, parsedContext.data);
if (
(entry.tagKeys && entry.tagKeys.length > 0) ||
(entry.ratedFactors && entry.ratedFactors.length > 0)
+146 -106
View File
@@ -25,6 +25,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a";
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import {
@@ -114,6 +115,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
moodTag: { select: { key: true, kind: true } },
},
},
// v1.38 — the day context, so the edit dialog can pre-populate the
// sections without a second round trip per row. Sparse by design: most
// rows carry none and the join costs nothing on those.
context: true,
},
}),
prisma.moodEntry.count({ where }),
@@ -124,7 +129,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: { total, limit, offset },
});
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
// v1.37 — the five level-A values under the keys the create path takes,
// so the edit form reads back what it wrote instead of mapping column
@@ -145,6 +150,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
key: link.moodTag.key,
rating: link.rating as number,
})),
// v1.38 — the day context, or null. The ciphertext never leaves the
// server: `contextForWire` reads the note and drops the column.
context: context ? contextForWire(context) : null,
}));
return apiSuccess({
@@ -207,6 +215,7 @@ async function postMoodEntry(request: NextRequest) {
tagKeys,
ratedFactors,
note,
context,
moodLoggedAt,
source,
externalId,
@@ -257,118 +266,141 @@ async function postMoodEntry(request: NextRequest) {
// tag-link failure must roll the entry back too — otherwise a client
// retry on the 5xx mints a duplicate entry. The tx client is threaded
// through the helper so both writes commit (or abort) together.
const { entry, persistedTagKeys, persistedRatedFactors } =
await prisma.$transaction(async (tx) => {
// v1.12.1 — when the client supplies a source-stable `externalId`,
// upsert on the NULL-distinct `(userId, source, externalId)` key so
// a re-post with the same id updates the existing row in place
// (idempotent re-import) instead of minting a duplicate or 409-ing.
// Without an `externalId`, fall back to the legacy first-write
// `create` exactly as before — a same-tuple re-post then trips the
// `(userId, date, moodLoggedAt)` unique and surfaces as a 409 below.
const created = externalId
? await tx.moodEntry.upsert({
where: {
userId_source_externalId: {
userId: user.id,
source: resolvedSource,
externalId,
},
},
create: {
const {
entry,
persistedTagKeys,
persistedRatedFactors,
persistedContext,
contextOutcome,
} = await prisma.$transaction(async (tx) => {
// v1.12.1 — when the client supplies a source-stable `externalId`,
// upsert on the NULL-distinct `(userId, source, externalId)` key so
// a re-post with the same id updates the existing row in place
// (idempotent re-import) instead of minting a duplicate or 409-ing.
// Without an `externalId`, fall back to the legacy first-write
// `create` exactly as before — a same-tuple re-post then trips the
// `(userId, date, moodLoggedAt)` unique and surfaces as a 409 below.
const created = externalId
? await tx.moodEntry.upsert({
where: {
userId_source_externalId: {
userId: user.id,
date,
tz,
mood,
score,
...levelA,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
source: resolvedSource,
externalId,
moodLoggedAt,
},
// A re-post restates pleasantness, because the label it carries
// always implies one and a stale A1 beside a changed mood would
// contradict the entry's own face. It does NOT restate the other
// four: `levelA` carries them only when this request did, so a
// client re-posting without sliders — the phone, whose build has
// none — leaves the stress and energy somebody answered on the
// web exactly where they were. An explicit null clears one.
update: {
date,
tz,
mood,
score,
...levelA,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
moodLoggedAt,
},
})
: await tx.moodEntry.create({
data: {
userId: user.id,
date,
tz,
mood,
score,
// A fresh row: an omitted dimension lands as NULL, which is
// the same thing the upsert's arms mean by omitting it.
...levelA,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
source: resolvedSource,
moodLoggedAt,
},
});
},
create: {
userId: user.id,
date,
tz,
mood,
score,
...levelA,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
source: resolvedSource,
externalId,
moodLoggedAt,
},
// A re-post restates pleasantness, because the label it carries
// always implies one and a stale A1 beside a changed mood would
// contradict the entry's own face. It does NOT restate the other
// four: `levelA` carries them only when this request did, so a
// client re-posting without sliders — the phone, whose build has
// none — leaves the stress and energy somebody answered on the
// web exactly where they were. An explicit null clears one.
update: {
date,
tz,
mood,
score,
...levelA,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
moodLoggedAt,
},
})
: await tx.moodEntry.create({
data: {
userId: user.id,
date,
tz,
mood,
score,
// A fresh row: an omitted dimension lands as NULL, which is
// the same thing the upsert's arms mean by omitting it.
...levelA,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
source: resolvedSource,
moodLoggedAt,
},
});
if (
(tagKeys && tagKeys.length > 0) ||
(ratedFactors && ratedFactors.length > 0)
) {
// Unknown / non-RATED keys are dropped inside the helper (the
// catalog is the source of truth). An out-of-scale rating
// throws `RatedFactorOutOfRangeError`, rolling the tx back.
await createTagLinks(
created.id,
user.id,
tagKeys ?? [],
tx,
ratedFactors ?? [],
);
}
if (
(tagKeys && tagKeys.length > 0) ||
(ratedFactors && ratedFactors.length > 0)
) {
// Unknown / non-RATED keys are dropped inside the helper (the
// catalog is the source of truth). An out-of-scale rating
// throws `RatedFactorOutOfRangeError`, rolling the tx back.
await createTagLinks(
created.id,
user.id,
tagKeys ?? [],
tx,
ratedFactors ?? [],
);
}
// v1.8.5 / v1.12.0 — read the persisted links back so the create
// response mirrors the list GET shape exactly: binary keys and
// rated factors split by `kind` (unknown keys already filtered).
const links = await tx.moodEntryTagLink.findMany({
where: { moodEntryId: created.id },
select: {
rating: true,
moodTag: { select: { key: true, kind: true } },
},
});
return {
entry: created,
persistedTagKeys: links
.filter((link) => link.moodTag.kind !== "RATED")
.map((link) => link.moodTag.key),
persistedRatedFactors: links
.filter(
(link) => link.moodTag.kind === "RATED" && link.rating !== null,
)
.map((link) => ({
key: link.moodTag.key,
rating: link.rating as number,
})),
};
// v1.38 — the day context, in the SAME transaction as the entry and
// its links, for the same reason they are: it is user-intended
// content, not a cache, so a context write that fails has to roll the
// entry back rather than leave a client retrying a 5xx into a
// duplicate entry. An absent `context` key leaves a stored one alone;
// a context that says nothing removes the row.
const contextOutcome = await persistMoodContext(
tx,
created.id,
user.id,
context,
);
const storedContext = await tx.moodContext.findUnique({
where: { moodEntryId: created.id },
});
// v1.8.5 / v1.12.0 — read the persisted links back so the create
// response mirrors the list GET shape exactly: binary keys and
// rated factors split by `kind` (unknown keys already filtered).
const links = await tx.moodEntryTagLink.findMany({
where: { moodEntryId: created.id },
select: {
rating: true,
moodTag: { select: { key: true, kind: true } },
},
});
return {
entry: created,
contextOutcome,
persistedContext: storedContext,
persistedTagKeys: links
.filter((link) => link.moodTag.kind !== "RATED")
.map((link) => link.moodTag.key),
persistedRatedFactors: links
.filter(
(link) => link.moodTag.kind === "RATED" && link.rating !== null,
)
.map((link) => ({
key: link.moodTag.key,
rating: link.rating as number,
})),
};
});
await auditLog("moodEntry.create", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -377,7 +409,11 @@ async function postMoodEntry(request: NextRequest) {
annotate({
action: { name: "mood-entries.create" },
meta: { moodEntryId: entry.id, mood },
// `mood_context` says written / removed / untouched rather than a
// boolean, because "the request said nothing" and "the request cleared
// it" are different acts and a dashboard that folded them together
// could not tell an abandoned section from a deliberate one.
meta: { moodEntryId: entry.id, mood, mood_context: contextOutcome },
});
// v1.4.34 IW-G — bust per-user mood + achievements + analytics caches.
@@ -411,6 +447,10 @@ async function postMoodEntry(request: NextRequest) {
tagKeys: persistedTagKeys,
// v1.12.0 — rated factors with their per-entry score.
ratedFactors: persistedRatedFactors,
// v1.38 — the stored context, lists decoded and note decrypted. Null
// when the entry carries none, which is a different answer from an
// object full of nulls and reads as one.
context: persistedContext ? contextForWire(persistedContext) : null,
},
201,
);
+93
View File
@@ -0,0 +1,93 @@
/**
* `GET /api/mood/linked-context?date=YYYY-MM-DD&tz=…` — what the modules that
* own sleep, activity, vitals and body symptoms already know about one day.
*
* Keyed by the local day rather than by an entry id, deliberately: the capture
* sheet needs these figures BEFORE an entry exists, and a route that could
* only answer for a saved row would leave the one surface that most wants them
* unable to ask. The capture sheet sends the browser's zone, which is the zone
* the day it is about to write will be anchored to; the edit dialog sends the
* entry's stored `tz`, so an entry logged in another country keeps its own day
* boundaries instead of being re-read under whichever zone the correction is
* being made from.
*
* Nothing here is stored on the mood row. Every figure is read from its owning
* module on each request, which is what makes a corrected sleep session show
* up on the mood entry with nothing to re-sync.
*
* **This route declares `record`, not `mind`, and that is the whole security
* decision on it.** Its payload is sleep minutes, steps, active energy,
* resting heart rate, heart-rate variability and an illness day-log with its
* symptom keys — the `measurements` and `illness` sections, reached from a
* mood surface. Declared as `mind` it was a hole with a mood-shaped label on
* it: a delegate given only the mind section could walk arbitrary dates
* through this one URL and read two sections the owner had declined to share,
* and no mood entry needed to exist for any of those dates. `record` is the
* convention for a read that crosses sections (`/api/dashboard/snapshot`,
* `/api/daily/digest` are the precedents) and a scoped grant never reaches
* one, so a scoped delegate loses the linked block entirely rather than
* receiving a filtered version of it. That is the honest answer: a block that
* silently dropped the sections a grant does not cover would show a day that
* looks empty where the truth is that it is none of the reader's business.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { resolveLinkedDayContext } from "@/lib/mood/linked-context";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
export const dynamic = "force-dynamic";
const querySchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
// The zone the day is anchored to. Absent falls back to the account's own
// display timezone; the edit dialog passes the entry's stored `tz` so a row
// logged elsewhere keeps its own day boundaries.
tz: z.string().min(1).max(64).optional(),
});
export const GET = apiHandler(async (request: NextRequest) => {
// `record`: see the file header. A scoped grant is refused here by
// construction rather than served a narrowed payload.
const { user } = await requireRecordAuth("read", "record");
const gate = await requireModuleEnabled(user.id, "mood");
if (!gate.enabled) return gate.response;
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
annotate({
action: { name: "mood.linked-context.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "mood.linkedContext.invalid",
});
}
const linked = await resolveLinkedDayContext(user.id, {
date: parsed.data.date,
tz: parsed.data.tz ?? user.timezone ?? DEFAULT_TIMEZONE,
});
annotate({
action: { name: "mood.linked-context.read" },
// Which blocks the person can see at all, so a support question about a
// missing figure can be answered without asking them to describe their
// module settings.
meta: {
day: linked.day,
sleep_available: linked.sleep.available,
activity_available: linked.activity.available,
body_available: linked.body.available,
},
});
return apiSuccess(linked);
});
+31
View File
@@ -54,6 +54,8 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { readNote } from "@/lib/crypto/note-cipher";
import { levelAForWire } from "@/lib/mood/level-a";
import { contextForWire, type MoodContextWire } from "@/lib/mood/context";
import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client";
import {
decodeCursor,
@@ -107,6 +109,25 @@ interface MoodUpsert {
date: string;
mood: string;
score: number;
/**
* v1.38 — the five level-A self-state values, each 0-10 and each nullable.
* `a2` is inverse-oriented: a higher number means more stress, and a client
* that wants "up is better" flips the sign itself rather than the server
* storing a flipped value.
*
* Server-authoritative: `a1` is derived from the five-point label on every
* write, so a client that sends only `mood` still mirrors a complete row.
*/
a1: number | null;
a2: number | null;
a3: number | null;
a4: number | null;
a5: number | null;
/**
* v1.38 — the day's context, or null when the entry carries none. Null and
* an object of nulls are different answers and the feed keeps them apart.
*/
context: MoodContextWire | null;
tags: string | null;
note: string | null;
moodLoggedAt: string;
@@ -299,6 +320,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
date: true,
mood: true,
score: true,
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
context: true,
tags: true,
note: true,
noteEncrypted: true,
@@ -407,6 +434,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
date: row.date,
mood: row.mood,
score: row.score,
// Under the same wire keys the write path accepts, so a client reads
// back exactly what it can post.
...levelAForWire(row),
context: row.context ? contextForWire(row.context) : null,
tags: row.tags,
note: readNote(row.noteEncrypted, row.note),
moodLoggedAt: row.moodLoggedAt.toISOString(),
@@ -23,7 +23,7 @@ vi.mock("@/lib/db", () => ({
createMany: vi.fn(),
},
workoutSamples: {
createMany: vi.fn(),
createManyAndReturn: vi.fn(),
},
user: {
findUnique: vi.fn(),
@@ -109,6 +109,23 @@ function makeRequest(
});
}
/**
* `workout.findMany` serves two callers in this route: the pre-flight
* probe for rows that already exist (it passes a `where`), and — inside
* the fake above — the stand-in for `INSERT ... RETURNING` (`select`
* only). Keying on `select.id` alone stopped telling the two apart once
* the probe began selecting `id` so an enrichment can address the stored
* workout, so the RETURNING arm is the one WITHOUT a `where`.
*/
function mockInsertReturning(
rows: Array<{ id: string; source: string; externalId: string }>,
) {
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
where?: unknown;
select?: Record<string, unknown>;
}) => (args.where === undefined && args.select?.id ? rows : [])) as never);
}
function validWorkout(
externalId: string,
overrides: Record<string, unknown> = {},
@@ -161,7 +178,13 @@ beforeEach(() => {
);
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.workoutSamples.createMany).mockResolvedValue({ count: 0 });
// The series write reports which rows landed, so an enrichment can tell
// an attach from a row a concurrent writer got in first. The fake
// echoes every row back as landed.
vi.mocked(prisma.workoutSamples.createManyAndReturn).mockImplementation(
(async (args: { data: Array<{ workoutId: string }> }) =>
args.data.map((row) => ({ workoutId: row.workoutId }))) as never,
);
// v1.4.43 W9 — default to no per-user source-priority override so
// the write-time picker walks the canonical default ladder. Tests
// that exercise a custom ladder override this in-line.
@@ -434,25 +457,18 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => {
createdAt: new Date("2026-05-14T10:00:01.000Z"),
},
] as never);
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
select?: Record<string, unknown>;
}) => {
if (args.select?.id) {
return [
{
id: "apple-id",
source: "APPLE_HEALTH",
externalId: "shared-external-id",
},
{
id: "manual-id",
source: "MANUAL",
externalId: "shared-external-id",
},
];
}
return [];
}) as never);
mockInsertReturning([
{
id: "apple-id",
source: "APPLE_HEALTH",
externalId: "shared-external-id",
},
{
id: "manual-id",
source: "MANUAL",
externalId: "shared-external-id",
},
]);
const res = await POST(
makeRequest({
@@ -480,20 +496,13 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
// Post-insert lookup returns the newly-inserted workout id so the
// route can be attached by FK.
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
select?: { id?: true; source?: true; externalId?: true };
}) => {
if (args.select?.id) {
return [
{
id: "wkt-fresh-id",
source: "APPLE_HEALTH",
externalId: "uuid-with-route",
},
];
}
return [];
}) as never);
mockInsertReturning([
{
id: "wkt-fresh-id",
source: "APPLE_HEALTH",
externalId: "uuid-with-route",
},
]);
const res = await POST(
makeRequest({
@@ -523,20 +532,13 @@ describe("POST /api/workouts/batch — nested route attachment", () => {
describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
it("creates a WorkoutSamples row for an indoor workout (samples, no route)", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
select?: { id?: true };
}) => {
if (args.select?.id) {
return [
{
id: "wkt-indoor-id",
source: "APPLE_HEALTH",
externalId: "uuid-indoor",
},
];
}
return [];
}) as never);
mockInsertReturning([
{
id: "wkt-indoor-id",
source: "APPLE_HEALTH",
externalId: "uuid-indoor",
},
]);
const res = await POST(
makeRequest({
@@ -556,8 +558,9 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
// The route table is untouched — an indoor workout has no GPS route.
expect(prisma.workoutRoute.createMany).not.toHaveBeenCalled();
// The HR series lands in the dedicated child table by FK.
expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0];
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
.calls[0]?.[0];
const rows = (
call as {
data: Array<{
@@ -575,20 +578,13 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
it("attaches both a route and an HR series on one workout", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
select?: { id?: true };
}) => {
if (args.select?.id) {
return [
{
id: "wkt-both-id",
source: "APPLE_HEALTH",
externalId: "uuid-both",
},
];
}
return [];
}) as never);
mockInsertReturning([
{
id: "wkt-both-id",
source: "APPLE_HEALTH",
externalId: "uuid-both",
},
]);
const res = await POST(
makeRequest({
@@ -610,14 +606,15 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
);
expect(res.status).toBe(200);
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
expect(prisma.workoutSamples.createMany).toHaveBeenCalledTimes(1);
expect(prisma.workoutSamples.createManyAndReturn).toHaveBeenCalledTimes(1);
const routeRows = (
vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0] as {
data: Array<{ workoutId: string }>;
}
).data;
const sampleRows = (
vi.mocked(prisma.workoutSamples.createMany).mock.calls[0]?.[0] as {
vi.mocked(prisma.workoutSamples.createManyAndReturn).mock
.calls[0]?.[0] as {
data: Array<{ workoutId: string }>;
}
).data;
@@ -632,7 +629,7 @@ describe("POST /api/workouts/batch — per-workout HR series (v1.10.0)", () => {
makeRequest({ workouts: [validWorkout("uuid-no-series")] }),
);
expect(res.status).toBe(200);
expect(prisma.workoutSamples.createMany).not.toHaveBeenCalled();
expect(prisma.workoutSamples.createManyAndReturn).not.toHaveBeenCalled();
});
});
+128 -28
View File
@@ -30,8 +30,8 @@
* - `Content-Length` ceiling at 5 MB before parsing — anything
* larger returns 413 so the iOS client falls back to one workout
* per call.
* - Per-entry status (`inserted | duplicate | skipped`) so the iOS
* sync cursor can checkpoint accurately.
* - Per-entry status (`inserted | duplicate | enriched | skipped`) so
* the iOS sync cursor can checkpoint accurately.
*
* Idempotency contract:
* - HTTP-level via `Idempotency-Key` (replays cached envelope).
@@ -39,6 +39,13 @@
* composite index. Re-posting the same batch surfaces duplicates
* rather than failing the call.
*
* Heart-rate series enrichment: a re-post that matches a stored
* workout and carries a `samples` array attaches that series when the
* workout has none. The workout row itself stays first-write-wins —
* nothing on it is written, not one column — and a workout that already
* holds a series is a no-op, so the client's history sweep is repeatable
* as often as it likes. See the enrichment block below.
*
* Race reconciliation uses PostgreSQL's `INSERT ... RETURNING` through
* Prisma's `createManyAndReturn`. The returned composite keys identify the
* exact rows this request inserted, so per-entry statuses, child rows, and
@@ -66,10 +73,11 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { MAX_WORKOUTS_PER_BATCH } from "@/lib/validations/workout";
import {
createBatchWorkoutSchema,
MAX_WORKOUTS_PER_BATCH,
} from "@/lib/validations/workout";
INVALID_SAMPLES_REASON,
parseWorkoutBatch,
} from "@/lib/workouts/batch-ingest-parse";
import {
classifyExternalId,
unstableExternalIdMeta,
@@ -106,16 +114,14 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024;
/**
* Per-entry outcome the iOS client uses to advance its sync cursor.
* `inserted` and `duplicate` both indicate the row landed (or was
* already present); the client can checkpoint past them identically.
* `skipped` covers entries the server cannot durably store — currently
* unused by this endpoint (the Zod schema rejects malformed entries
* with a 422 before we reach the per-entry pass), but reserved on the
* envelope so the response shape parallels the measurements batch and
* future server-side validation can surface fine-grained skips without
* breaking the iOS DTO.
* `inserted`, `duplicate` and `enriched` all indicate the row landed (or
* was already present); the client can checkpoint past them identically.
* `enriched` additionally says the entry's heart-rate series was attached
* to a workout that had none, which is what lets a history sweep count
* its own progress. `skipped` covers entries the server refused: an
* unusable external id, or a `samples` array that failed validation.
*/
type EntryStatus = "inserted" | "duplicate" | "skipped";
type EntryStatus = "inserted" | "duplicate" | "enriched" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
@@ -251,15 +257,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
);
}
const parsed = createBatchWorkoutSchema.safeParse(rawBody);
if (!parsed.success) {
const issue = parsed.error.issues[0];
return apiError(issue?.message ?? "Invalid batch", 400, {
// Strict on every field, per-entry on `samples` alone — see
// `parseWorkoutBatch` for why the series is the one field whose
// failure must not take the rest of a history sweep down with it.
const parsed = parseWorkoutBatch(rawBody);
if (!parsed.ok) {
return apiError(parsed.message, 400, {
errorCode: "workout.batch.invalid",
});
}
const { workouts } = parsed.data;
const refusedSampleIndices = new Set(parsed.refusedSampleIndices);
// Pre-flight duplicate detection so we can return per-entry status.
// The composite unique index is `(userId, source, externalId)` —
@@ -289,6 +298,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
const results: EntryResult[] = new Array(workouts.length);
const unstableShapes: UnstableExternalIdShape[] = [];
const prepared: Prepared[] = workouts.flatMap((w, index) => {
// An entry whose series failed validation is refused whole. Storing
// the workout without the curve the caller asked us to keep would be
// a silent loss, and the row may well already exist anyway.
if (refusedSampleIndices.has(index)) {
results[index] = {
index,
status: "skipped",
reason: INVALID_SAMPLES_REASON,
};
return [];
}
// The `(userId, source, externalId)` dedup key is only idempotent
// while the id is STABLE across client launches. An id that rotates
// per process (an object description carrying a memory address)
@@ -408,6 +429,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
const survivors = prepared.filter((p) => survivingIndices.has(p.index));
let duplicateCount = droppedByWriteDedup.length;
let insertedCount = 0;
let enrichedCount = 0;
const insertedIdByIndex = new Map<number, string>();
if (survivors.length > 0) {
@@ -418,6 +440,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
.map((p) => p.dedupKey)
.filter((k): k is { source: string; externalId: string } => k !== null);
// The same probe answers both questions: does the row exist, and
// does it already carry a series. `id` comes along so an enrichment
// can address the stored workout without a second round trip.
const existing = dedupCandidates.length
? await prisma.workout.findMany({
where: {
@@ -427,30 +452,65 @@ async function postBatch(request: NextRequest): Promise<Response> {
externalId: k.externalId,
})),
},
select: { source: true, externalId: true },
select: {
id: true,
source: true,
externalId: true,
samples: { select: { workoutId: true } },
},
})
: [];
const existingSet = new Set(
existing.map((row) => `${row.source}::${row.externalId}`),
);
const existingByKey = new Map<string, { id: string; hasSeries: boolean }>();
for (const row of existing) {
if (row.externalId === null) continue;
existingByKey.set(`${row.source}::${row.externalId}`, {
id: row.id,
hasSeries: row.samples !== null,
});
}
const toInsert: Prepared[] = [];
/** Stored workouts this batch hands a heart-rate series to. */
const toEnrich: Array<{
index: number;
workoutId: string;
samples: NonNullable<Prepared["samples"]>;
}> = [];
/** Workouts already claimed by an earlier entry of THIS batch. */
const enrichClaimed = new Set<string>();
for (const p of survivors) {
const keyTuple = p.dedupKey
? `${p.dedupKey.source}::${p.dedupKey.externalId}`
: null;
if (keyTuple !== null && existingSet.has(keyTuple)) {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
const known = keyTuple !== null ? existingByKey.get(keyTuple) : undefined;
if (known) {
if (p.samples && !known.hasSeries && !enrichClaimed.has(known.id)) {
enrichClaimed.add(known.id);
// Enrichment. The status is assigned after the write, because
// a concurrent writer may get the series in first — in which
// case this entry is the plain duplicate it always was. A
// second entry of this batch naming the same workout takes the
// duplicate arm above: one workout, one series, one `enriched`.
toEnrich.push({
index: p.index,
workoutId: known.id,
samples: p.samples,
});
} else {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
}
} else {
results[p.index] = { index: p.index, status: "inserted" };
toInsert.push(p);
}
}
if (toInsert.length > 0) {
if (toInsert.length > 0 || toEnrich.length > 0) {
const CHUNK = 100;
/** Workout ids whose series row this request actually wrote. */
const seriesLanded = new Set<string>();
await prisma.$transaction(async (tx) => {
const withExternalId = toInsert.filter((p) => p.dedupKey !== null);
@@ -517,6 +577,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
// Enrichment rides the SAME child write as a fresh workout's
// series — one mechanism, one place where a series is stored.
// The only difference is where the `workoutId` came from: the
// pre-flight probe instead of this request's INSERT ... RETURNING.
// Nothing here writes to the workout row.
for (const e of toEnrich) {
samplesToInsert.push({
workoutId: e.workoutId,
samples: e.samples.samples as Prisma.InputJsonValue,
sampleCount: e.samples.sampleCount,
});
}
for (let i = 0; i < routesToInsert.length; i += CHUNK) {
await tx.workoutRoute.createMany({
data: routesToInsert.slice(i, i + CHUNK),
@@ -524,10 +597,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
});
}
for (let i = 0; i < samplesToInsert.length; i += CHUNK) {
await tx.workoutSamples.createMany({
// RETURNING names the rows that actually landed. `workoutId` is
// unique, so a workout that gained a series between the probe
// and this write is skipped here and reports as a duplicate —
// the re-post stays a no-op and never overwrites a stored curve.
const landed = await tx.workoutSamples.createManyAndReturn({
data: samplesToInsert.slice(i, i + CHUNK),
skipDuplicates: true,
select: { workoutId: true },
});
for (const row of landed) seriesLanded.add(row.workoutId);
}
});
@@ -540,6 +619,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
duplicateCount += 1;
}
}
// An enriched entry is still a duplicate of the workout row: the
// top-level counters count rows, and no row was inserted for it.
// The per-entry status carries what happened to the series.
for (const e of toEnrich) {
results[e.index] = {
index: e.index,
status: seriesLanded.has(e.workoutId) ? "enriched" : "duplicate",
};
if (seriesLanded.has(e.workoutId)) enrichedCount += 1;
duplicateCount += 1;
}
}
}
@@ -554,6 +645,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
processed: workouts.length,
inserted: insertedCount,
duplicates: duplicateCount,
enriched: enrichedCount,
skipped: skipped.length,
},
});
@@ -584,12 +676,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
}
// Counts only. The series itself never reaches a wide event.
annotate({
action: { name: "workout.batch.ingest" },
meta: {
processed: workouts.length,
inserted: insertedCount,
duplicates: duplicateCount,
enriched: enrichedCount,
skipped: skipped.length,
},
});
@@ -598,11 +692,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
// caches when at least one row landed. Workouts ride on the
// measurements bucket because achievements / analytics also touch
// workout-derived metrics.
if (insertedCount > 0) {
// An enrichment changes what the workout reads as — the detail seam
// gains its curve, the list gains its glyph — so it busts the cache
// too, even though no workout row was written.
if (insertedCount > 0 || enrichedCount > 0) {
invalidateUserMeasurements(user.id);
}
if (insertedCount > 0) {
// One arrival per exact INSERT ... RETURNING winner. Historical rows still
// stop at the shared salience classifier before any queue work.
// An enrichment raises none: the workout arrived when it was stored.
void emitWorkoutArrivals(user.id, prepared, insertedIdByIndex).catch(
() => {},
);
+3 -16
View File
@@ -9,7 +9,6 @@ import { useTranslations } from "@/lib/i18n/context";
import { useWorkoutDetail } from "@/hooks/use-workouts";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
import { BackLink } from "@/components/ui/back-link";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
@@ -31,7 +30,7 @@ import {
* Layout, top to bottom (mobile-first single column):
* hero header → reserved Activity-Insight seam (renders nothing today)
* → stats grid + sport-average line → HR curve → effort zones → GPS
* route → per-km splits → "that day" → coach launch.
* route → per-km splits → "that day".
*
* "That day" answers the last question the page is for: what the rest of
* the day looked like around the session. It renders the day's own pulse
@@ -40,8 +39,8 @@ import {
*
* Every data-less section returns `null` (hide, don't render empty), so
* an aggregates-only workout (a Strava ride with no wearable, a manual
* entry) reads as hero + stats + "that day" + coach — compact but
* honest, no empty shells.
* entry) reads as hero + stats + "that day" — compact but honest, no
* empty shells.
*
* Data flows from `GET /api/workouts/{id}?compact=1` (v1.4.32 + the #67
* enrichment fields). The `canonicalId` pointer still resolves a
@@ -120,18 +119,6 @@ export default function InsightsWorkoutDetailPage({
<WorkoutDetailRoute workout={data} />
<WorkoutDetailSplits workout={data} />
<WorkoutDetailDayContext workout={data} />
{/* 2026-07-17 UX-flows audit F6-1 — `workouts` narrows the
snapshot the first coach turn reads. v1.31.0 — `workoutId` pins
THIS session's own numbers as one additional snapshot section, so
the answer is about this workout rather than about training in
general, and the opener auto-sends so it lands directly. */}
<CoachLaunchButton
scope={{ metric: "workouts" }}
workoutId={data.id}
label={t("insights.workouts.askWhy")}
prefill={t("insights.workouts.askWhyPrompt")}
autoSend
/>
</>
)}
</SubPageShell>
@@ -3,7 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.8.6 QA — the in-target share must appear exactly once on the mood
@@ -11,10 +10,18 @@ import { queryKeys } from "@/lib/query-keys";
* (inTargetPct present) the same-number `in-target` takeaway is dropped
* from the narrative feed so the percentage is not duplicated one row
* below the headline tile.
*
* Driven against `<MoodInsightsBreakdowns>` rather than its parent since the
* breakdown cluster moved into its own `next/dynamic` chunk: under a static
* render the parent paints the loader skeleton and nothing this file is about
* is in the markup. The breakdown module takes the resolved payload as a prop,
* so the aggregate no longer has to be seeded into a query cache to reach the
* assertion. The provider and the auth stub stay because the "what stands
* out" card inside the cluster fetches the discovery surface itself.
*/
// `useAuth` runs its own /me query; stub it to a signed-in user so the
// section's `enabled: isAuthenticated` gate opens without a fetch.
// self-fetching card inside the cluster resolves without a request.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1" },
@@ -25,7 +32,8 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
import { MoodInsightsSections } from "../mood-insights-sections";
import { MoodInsightsBreakdowns } from "../mood-insights-breakdowns";
import type { MoodInsightsResponse } from "../mood-insights-shared";
type Response = {
summary: { totalEntries: number; inTargetPct: number | null };
@@ -62,12 +70,12 @@ function renderWithData(data: Response) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
// Seed the cache so the synchronous SSR render sees resolved data.
queryClient.setQueryData(queryKeys.moodInsights(), data);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<MoodInsightsSections />
<MoodInsightsBreakdowns
data={data as unknown as MoodInsightsResponse}
/>
</I18nProvider>
</QueryClientProvider>,
);
@@ -105,7 +113,7 @@ function baseData(inTargetPct: number | null): Response {
};
}
describe("<MoodInsightsSections> — in-target dedup", () => {
describe("<MoodInsightsBreakdowns> — in-target dedup", () => {
it("renders the tile and drops the duplicate in-target narrative", () => {
const html = renderWithData(baseData(72));
// The tile carries the percentage exactly once.
@@ -4,6 +4,14 @@ import { join } from "node:path";
const COMPONENT_DIR = join(__dirname, "..");
const MOOD_LIST = join(__dirname, "../../../mood/mood-list.tsx");
/**
* The two modules the mood insights surface is rendered from. The breakdown
* cluster lives behind `next/dynamic` in its own file; both are the surface.
*/
const INSIGHTS_SURFACE_FILES = [
join(COMPONENT_DIR, "mood-insights-sections.tsx"),
join(COMPONENT_DIR, "mood-insights-breakdowns.tsx"),
];
/**
* v1.8.6 — the notes timeline display is removed from the mood insights
@@ -19,23 +27,31 @@ describe("mood notes timeline removal", () => {
});
it("no longer renders the notes timeline from the insights sections", () => {
const src = readFileSync(
join(COMPONENT_DIR, "mood-insights-sections.tsx"),
"utf8",
);
expect(src).not.toContain("MoodNotesTimeline");
expect(src).not.toContain("notesTimeline");
expect(src).not.toContain("notesTimelineTitle");
// Both halves of the surface: the eager module that paints the calendar
// and the assessment, and the deferred module that carries every
// breakdown. A guard reading only one of them would go quiet the moment
// the other grew a notes feed.
for (const file of INSIGHTS_SURFACE_FILES) {
const src = readFileSync(file, "utf8");
expect(src).not.toContain("MoodNotesTimeline");
expect(src).not.toContain("notesTimeline");
expect(src).not.toContain("notesTimelineTitle");
}
});
it("mounts the narrative feed in the insights sections", () => {
const src = readFileSync(
join(COMPONENT_DIR, "mood-insights-sections.tsx"),
"utf8",
);
// v1.12.7 — the narrative one-liners now ride the merged "What stands out"
// card (`MoodWhatStandsOut`), which renders `MoodNarrativeFeed` internally.
// The structural guard tracks the mount point on the sections surface.
//
// v1.38 — that mount point moved into `mood-insights-breakdowns.tsx` when
// the below-the-fold cluster went behind `next/dynamic`. Read as the union
// of the two files, so the guard asserts the card is mounted SOMEWHERE on
// the surface rather than in one particular file — which is what it was
// always about.
const src = INSIGHTS_SURFACE_FILES.map((f) => readFileSync(f, "utf8")).join(
"\n",
);
expect(src).toContain("MoodWhatStandsOut");
expect(src).toContain("MoodInTargetTile");
});
@@ -0,0 +1,74 @@
"use client";
/**
* How the day's mood sat on days carrying each context value.
*
* The copy rule this component exists to hold, stated once so nobody has to
* infer it from the strings: **no sentence here names a cause.** Not "family
* lowers your mood", not "overtime costs you two points". What the data
* supports is "on the days recorded so far with X, the average was lower", and
* that is what it says — with the two counts on the same line as the claim,
* not behind a tooltip, because a reader who cannot see that a row rests on
* six days cannot weigh it.
*
* The rows arriving here have already cleared the day floors and a
* family-wide Benjamini-Hochberg correction. Nothing in this file re-filters
* them; a second threshold in the presentation layer would be a second place
* to get the statistics wrong.
*/
import { useTranslations } from "@/lib/i18n/context";
import { contextValueLabelKey } from "@/lib/mood/context-vocabulary";
import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
export function MoodContextComparison({
rows,
}: {
rows: ContextComparisonRow[];
}) {
const { t } = useTranslations();
if (rows.length === 0) return null;
return (
<div className="space-y-3" data-slot="mood-context-comparison">
<p className="text-muted-foreground text-sm">
{t("insights.mood.contextComparison.description")}
</p>
<ul className="space-y-3">
{rows.map((row) => {
const label = t(contextValueLabelKey(row.field, row.value));
const direction =
row.delta < 0
? t("insights.mood.contextComparison.lower")
: t("insights.mood.contextComparison.higher");
return (
<li
key={`${row.field}:${row.value}`}
className="border-border/60 space-y-1 border-t pt-3 first:border-t-0 first:pt-0"
data-slot="mood-context-comparison-row"
>
<p className="text-foreground text-sm">
{t("insights.mood.contextComparison.statement", {
label,
direction,
withAvg: row.withAvg.toFixed(1),
withoutAvg: row.withoutAvg.toFixed(1),
})}
</p>
{/* The counts ride with the statement, in the same block a
screen reader reaches in the same breath. */}
<p className="text-muted-foreground text-xs tabular-nums">
{t("insights.mood.contextComparison.counts", {
withDays: String(row.withDays),
withoutDays: String(row.withoutDays),
})}
</p>
</li>
);
})}
</ul>
<p className="text-muted-foreground text-xs">
{t("insights.mood.contextComparison.caveat")}
</p>
</div>
);
}
@@ -0,0 +1,276 @@
"use client";
/**
* Every mood breakdown below the fold, in one deferred module.
*
* The classification tiles, the "what stands out" card, the four Recharts
* mini-charts, both tag axes, the influence board, the two crosstabs, the
* day-context comparison and the correlation cards. Between them they pull in
* roughly a dozen card components and their icon sets, and none of it is
* visible until the reader has scrolled past the calendar, the line chart, the
* target card and the assessment.
*
* So it is a module rather than a section of its sibling, and its sibling
* loads it through `next/dynamic` — the same treatment the insights mother
* page gives its below-the-hero blocks. Splitting it out is what keeps the
* eager graph for this route down to the calendar and the assessment: while
* every one of these cards was a static import of `mood-insights-sections`,
* fetching the page paid for all of them before painting anything.
*
* It takes the resolved payload as a prop instead of running its own query.
* TanStack would have deduped a second read, but a prop means this module
* cannot render a different snapshot of the same aggregate from the one the
* regions above it drew.
*/
import {
BarChart3,
Briefcase,
CalendarRange,
Clock,
Gauge,
Grid3x3,
Link2,
Tag,
Tags,
TrendingUp,
} from "lucide-react";
import dynamic from "next/dynamic";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { SectionCard, type MoodInsightsResponse } from "./mood-insights-shared";
// v1.12.1 — the four Recharts mini-charts here are deferred via
// `next/dynamic`. The mood hero line chart above them is already dynamic on
// the page, so static-importing these pulled Recharts into the initial chunk
// for no first-paint benefit. Each loader paints a skeleton sized to the
// chart's own band so the deferred chunk arrives without a layout shift
// (charts stay Recharts, visually identical).
//
// v1.16.7 established the shared-barrel pattern for the group; all four
// loaders resolve the app-wide chart-runtime boundary, so they share the ONE
// recharts chunk with every other chart surface: the cards reveal together,
// and recharts ships exactly once for the whole app.
const MoodDimensionTrends = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDimensionTrends,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,34vh,220px)] w-full rounded-md" />
),
},
);
const MoodDistributionChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDistributionChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodWeekdayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodWeekdayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodTimeOfDayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodTimeOfDayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,38vh,220px)] w-full rounded-md" />
),
},
);
import { MoodContextComparison } from "./mood-context-comparison";
import { MoodTagBreakdown } from "./mood-tag-breakdown";
import { MoodCorrelationCards } from "./mood-correlation-cards";
import { MoodStructuredTagBreakdown } from "./mood-structured-tag-breakdown";
import { MoodWhatStandsOut } from "./mood-what-stands-out";
import { MoodInTargetTile } from "./mood-in-target-tile";
import { MoodStabilityTile } from "./mood-stability-tile";
import { MoodTagInfluence } from "./mood-tag-influence";
import { MoodTagMetricCrosstab } from "./mood-tag-metric-crosstab";
import { MoodFactorMetricCrosstab } from "./mood-factor-metric-crosstab";
export function MoodInsightsBreakdowns({
data,
}: {
data: MoodInsightsResponse;
}) {
const { t } = useTranslations();
// The in-target tile is the canonical surface for the in-target share.
// When it renders (inTargetPct present) drop the same-number `in-target`
// takeaway from the feed so the percentage appears exactly once on the
// page. The narrative still rides the API/LLM payload unchanged.
const inTargetShown = data.summary.inTargetPct != null;
const narratives = inTargetShown
? data.narratives.filter((item) => item.kind !== "in-target")
: data.narratives;
const hasTags = data.tags.length > 0;
const hasStructuredTags = data.structuredTags.length > 0;
const hasStabilityTile = data.stability != null;
const hasInTargetTile = data.summary.inTargetPct != null;
// F1 — fold the structured + flat influence axes into one list ranked by
// absolute delta, so the strongest "this tag moves my mood" rows lead
// regardless of which axis they came from. Defensive against a stale
// server-cache payload minted before the v1.11.5 shape landed (the
// aggregate is cached up to 60 s; a rollout can serve one old read).
const influenceRows = [
...(data.tagInfluence?.structured ?? []),
...(data.tagInfluence?.flat ?? []),
].sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
const hasInfluence = influenceRows.length > 0;
const crosstabRows = data.tagMetricCrosstab ?? [];
const hasCrosstab = crosstabRows.length > 0;
// Defensive against a stale server-cache payload minted before this shape
// landed, the same way the influence rows above are.
const contextRows = data.contextComparison ?? [];
const hasContextComparison = contextRows.length > 0;
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
const hasDimensions = dimensionSummaries.some((d) => d.present);
return (
// v1.12.7 — "rest" region. The Stimmungskalender (now above the chart) and
// the better-days Einschätzung (now right after the Ziel card) render in
// their own regions above; this region carries the Einordnung classification
// tiles (in-target share + stability band) FIRST, then the "what stands
// out" card, then the descriptive breakdown sections. The classification
// answers "where do I stand", and the rest is the supporting detail.
<div className="space-y-4">
{/* Einordnung — the classification tiles. */}
{(hasInTargetTile || hasStabilityTile) && (
// Two-up only when BOTH tiles render; a lone tile spans full width so
// it never leaves a half-width orphan with dead space beside it.
<div
className={cn(
"grid gap-4",
hasInTargetTile && hasStabilityTile && "sm:grid-cols-2",
)}
>
{hasInTargetTile && (
<MoodInTargetTile pct={data.summary.inTargetPct} />
)}
{hasStabilityTile && <MoodStabilityTile stability={data.stability} />}
</div>
)}
{/* v1.12.7 — the single "What stands out" card folds the narrative
one-liners AND the FDR-controlled discovered relations into one tile
(was two separate cards). Self-fetches the discovery surface and
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
{/* v1.37 — the five level-A dimensions. Renders only once at least one
of them carries a value; an account that has never opened the detail
section sees the page it saw before. */}
{hasDimensions && (
<SectionCard title={t("insights.mood.dimensions.title")} icon={Gauge}>
<MoodDimensionTrends dimensions={dimensionSummaries} />
</SectionCard>
)}
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard
title={t("insights.mood.distributionTitle")}
icon={BarChart3}
>
<MoodDistributionChart distribution={data.distribution} />
</SectionCard>
<SectionCard
title={t("insights.mood.weekdayTitle")}
icon={CalendarRange}
>
<MoodWeekdayChart weekday={data.weekday} />
</SectionCard>
</div>
{data.timeOfDay.reliable && (
<SectionCard title={t("insights.mood.timeOfDay.title")} icon={Clock}>
<MoodTimeOfDayChart pattern={data.timeOfDay} />
</SectionCard>
)}
{hasStructuredTags && (
<SectionCard title={t("insights.mood.structuredTagsTitle")} icon={Tags}>
<MoodStructuredTagBreakdown tags={data.structuredTags} />
</SectionCard>
)}
{hasTags && (
<SectionCard title={t("insights.mood.tagsTitle")} icon={Tag}>
<MoodTagBreakdown tags={data.tags} />
</SectionCard>
)}
{hasInfluence && (
<SectionCard
title={t("insights.mood.influence.title")}
icon={TrendingUp}
>
<MoodTagInfluence rows={influenceRows} />
</SectionCard>
)}
{hasCrosstab && (
<SectionCard title={t("insights.mood.crosstab.title")} icon={Grid3x3}>
<MoodTagMetricCrosstab rows={crosstabRows} />
</SectionCard>
)}
{hasContextComparison && (
<SectionCard
title={t("insights.mood.contextComparison.title")}
icon={Briefcase}
>
<MoodContextComparison rows={contextRows} />
</SectionCard>
)}
{hasFactorCrosstab && (
<SectionCard
title={t("insights.mood.factorCrosstab.title")}
icon={Gauge}
>
<MoodFactorMetricCrosstab rows={factorCrosstabRows} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.correlationsTitle")} icon={Link2}>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.correlationsDescription")}
</p>
<MoodCorrelationCards
sleep={data.correlations.sleep}
steps={data.correlations.steps}
pulse={data.correlations.pulse}
weight={data.correlations.weight}
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
/>
</SectionCard>
</div>
);
}
@@ -3,195 +3,78 @@
import dynamic from "next/dynamic";
import { useQuery } from "@tanstack/react-query";
import type { ComponentType } from "react";
import {
BarChart3,
CalendarDays,
CalendarRange,
Clock,
Gauge,
Grid3x3,
Link2,
Sparkles,
Tag,
Tags,
TrendingUp,
} from "lucide-react";
import { CalendarDays, Sparkles } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { MoodHeatmap } from "@/components/charts/mood-heatmap";
// v1.12.1 — the three Recharts mini-charts on this below-fold cluster are
// deferred via `next/dynamic`. The mood hero line chart above them is
// already dynamic on the page, so static-importing these pulled Recharts
// into the initial chunk for no first-paint benefit. Each loader paints a
// skeleton sized to the chart's own band so the deferred chunk arrives
// without a layout shift (charts stay Recharts, visually identical). Types
// stay value-free imports so they don't drag the chunk back in.
import type { MoodDistributionRow } from "./mood-distribution-chart";
import type { MoodDimensionSummaryData } from "./mood-dimension-trends";
import type { MoodWeekdayRow } from "./mood-weekday-chart";
import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart";
// v1.16.7 established the shared-barrel pattern for the trio; all three
// loaders now resolve the app-wide chart-runtime boundary, so they share
// the ONE recharts chunk with every other chart surface: the cards reveal
// together, and recharts ships exactly once for the whole app.
const MoodDimensionTrends = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDimensionTrends,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,34vh,220px)] w-full rounded-md" />
),
},
);
const MoodDistributionChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDistributionChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodWeekdayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodWeekdayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(120px,26vh,150px)] w-full rounded-md" />
),
},
);
const MoodTimeOfDayChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodTimeOfDayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,38vh,220px)] w-full rounded-md" />
),
},
);
import { MoodTagBreakdown, type MoodTagRow } from "./mood-tag-breakdown";
import {
MoodCorrelationCards,
type MoodMetricCorrelationData,
} from "./mood-correlation-cards";
import {
MoodStructuredTagBreakdown,
type MoodStructuredTagRow,
} from "./mood-structured-tag-breakdown";
import { type MoodNarrativeItem } from "./mood-narrative-feed";
import { MoodWhatStandsOut } from "./mood-what-stands-out";
import { MoodInTargetTile } from "./mood-in-target-tile";
import {
MoodStabilityTile,
type MoodStabilityData,
} from "./mood-stability-tile";
import {
MoodTagInfluence,
type MoodTagInfluenceRow,
} from "./mood-tag-influence";
import { MoodBetterDays, type MoodBetterDayFactor } from "./mood-better-days";
import {
MoodTagMetricCrosstab,
type MoodTagMetricCrosstabRow,
} from "./mood-tag-metric-crosstab";
import {
MoodFactorMetricCrosstab,
type MoodFactorMetricCrosstabRow,
} from "./mood-factor-metric-crosstab";
import { SectionCard, type MoodInsightsResponse } from "./mood-insights-shared";
import { apiGet } from "@/lib/api/api-fetch";
/**
* Every breakdown below the fold, in its own chunk.
*
* The calendar and the assessment are what this route paints first; the
* dozen-odd breakdown cards under them — both tag axes, the influence board,
* the two crosstabs, the day-context comparison, the four Recharts
* mini-charts, the correlation cards — are not visible until the reader has
* scrolled past the line chart and the target card. Static-importing them
* made every cold mount of `/insights/mood` pay for all of them before
* painting anything, and it is what put this route on its bundle ceiling.
*
* Deferred behind `next/dynamic` like the below-the-hero blocks on the
* insights mother page. `ssr: false` because the whole subtree is a client
* render off a client query anyway, and the skeleton holds the block open at
* roughly the height of the classification tiles so the chunk lands without
* shoving the page.
*/
/**
* The better-days assessment, deferred for one specific reason: it renders the
* catalogue's tag icons, and that resolver
* (`@/components/mood/mood-tag-icons`) statically imports about a hundred and
* sixty lucide icons. Eager, that map rode the first-load graph of a route
* whose first paint is a calendar. The card itself sits a screen down, under
* the line chart and the target card.
*
* It shares the icon map with the breakdown module below, so the two resolve
* out of the same lazy group rather than duplicating it.
*/
const MoodBetterDays = dynamic(
() =>
import("./mood-better-days").then((mod) => ({
default: mod.MoodBetterDays,
})),
{
ssr: false,
loading: () => <Skeleton className="min-h-24 w-full rounded-md" />,
},
);
const MoodInsightsBreakdowns = dynamic(
() =>
import("./mood-insights-breakdowns").then((mod) => ({
default: mod.MoodInsightsBreakdowns,
})),
{
ssr: false,
loading: () => <Skeleton className="min-h-48 w-full rounded-xl" />,
},
);
/**
* v1.8.5 — additional Mood Insights sections.
*
* Reads the pre-computed aggregate bundle from `/api/mood/insights`
* (cheap cached server read, no LLM) and paints the heatmap,
* distribution, weekday pattern, tag breakdown, and mood × metric
* correlation cards. Renders nothing while loading / on error / on an
* empty data set so the page degrades gracefully to the line chart.
* Reads the pre-computed aggregate bundle from `/api/mood/insights` (cheap
* cached server read, no LLM) and paints the calendar and the assessment
* itself, handing the payload to the deferred breakdown module for everything
* under them. Renders nothing while loading / on error / on an empty data set
* so the page degrades gracefully to the line chart.
*/
interface MoodInsightsResponse {
summary: {
totalEntries: number;
inTargetPct: number | null;
};
heatmap: {
windowDays: number;
cells: Array<{ date: string; score: number; samples: number }>;
};
distribution: MoodDistributionRow[];
// Optional only to tolerate a stale pre-v1.37 cached payload during a
// rollout; the live endpoint always populates it.
dimensions?: MoodDimensionSummaryData[];
weekday: MoodWeekdayRow[];
timeOfDay: MoodTimeOfDayPattern;
stability: MoodStabilityData | null;
tags: MoodTagRow[];
structuredTags: MoodStructuredTagRow[];
// Optional only to tolerate a stale pre-v1.11.5 cached payload during a
// rollout; the live endpoint always populates both.
tagInfluence?: {
flat: MoodTagInfluenceRow[];
structured: MoodTagInfluenceRow[];
};
betterDays?: MoodBetterDayFactor[];
// Optional only to tolerate a stale pre-v1.12.0 cached payload during a
// rollout; the live endpoint always populates it.
tagMetricCrosstab?: MoodTagMetricCrosstabRow[];
// Optional only to tolerate a stale pre-v1.14.0 cached payload during a
// rollout; the live endpoint always populates it.
factorCrosstab?: MoodFactorMetricCrosstabRow[];
narratives: MoodNarrativeItem[];
correlations: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
weight: MoodMetricCorrelationData;
bloodPressureSystolic: MoodMetricCorrelationData;
};
}
function SectionCard({
title,
icon,
children,
}: {
title: string;
// v1.12.6 — every mood section header now routes through `TileHeader`
// (icon + white heading) so the subpage reads as one card language.
icon: ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<Card>
<CardHeader>
<TileHeader icon={icon} title={title} />
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
/**
* v1.12.7 — the mood spine is split into three render regions so the page can
* thread the operator's exact top-to-bottom order without prop-drilling the
@@ -242,40 +125,8 @@ export function MoodInsightsSections({
const heatmapCells = Object.fromEntries(
data.heatmap.cells.map((cell) => [cell.date, cell]),
);
const hasTags = data.tags.length > 0;
const hasStructuredTags = data.structuredTags.length > 0;
// The in-target tile is the canonical surface for the in-target share.
// When it renders (inTargetPct present) drop the same-number `in-target`
// takeaway from the feed so the percentage appears exactly once on the
// page. The narrative still rides the API/LLM payload unchanged.
const inTargetShown = data.summary.inTargetPct != null;
const narratives = inTargetShown
? data.narratives.filter((item) => item.kind !== "in-target")
: data.narratives;
const hasStabilityTile = data.stability != null;
const hasInTargetTile = data.summary.inTargetPct != null;
// F1 — fold the structured + flat influence axes into one list ranked by
// absolute delta, so the strongest "this tag moves my mood" rows lead
// regardless of which axis they came from. Defensive against a stale
// server-cache payload minted before the v1.11.5 shape landed (the
// aggregate is cached up to 60 s; a rollout can serve one old read).
const influenceRows = [
...(data.tagInfluence?.structured ?? []),
...(data.tagInfluence?.flat ?? []),
].sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
const hasInfluence = influenceRows.length > 0;
const betterDays = data.betterDays ?? [];
const hasBetterDays = betterDays.length > 0;
const crosstabRows = data.tagMetricCrosstab ?? [];
const hasCrosstab = crosstabRows.length > 0;
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
const hasDimensions = dimensionSummaries.some((d) => d.present);
// v1.12.7 — the Stimmungskalender is lifted above the line chart on the page,
// so it renders as its own region here.
@@ -321,115 +172,8 @@ export function MoodInsightsSections({
);
}
return (
// v1.12.7 — "rest" region. The Stimmungskalender (now above the chart) and
// the better-days Einschätzung (now right after the Ziel card) render in
// their own regions above; this region carries the Einordnung classification
// tiles (in-target share + stability band) FIRST, then the "what stands
// out" card, then the descriptive breakdown sections. The classification
// answers "where do I stand", and the rest is the supporting detail.
<div className="space-y-4">
{/* Einordnung — the classification tiles. */}
{(hasInTargetTile || hasStabilityTile) && (
// Two-up only when BOTH tiles render; a lone tile spans full width so
// it never leaves a half-width orphan with dead space beside it.
<div
className={cn(
"grid gap-4",
hasInTargetTile && hasStabilityTile && "sm:grid-cols-2",
)}
>
{hasInTargetTile && (
<MoodInTargetTile pct={data.summary.inTargetPct} />
)}
{hasStabilityTile && <MoodStabilityTile stability={data.stability} />}
</div>
)}
{/* v1.12.7 — the single "What stands out" card folds the narrative
one-liners AND the FDR-controlled discovered relations into one tile
(was two separate cards). Self-fetches the discovery surface and
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
{/* v1.37 — the five level-A dimensions. Renders only once at least one
of them carries a value; an account that has never opened the detail
section sees the page it saw before. */}
{hasDimensions && (
<SectionCard title={t("insights.mood.dimensions.title")} icon={Gauge}>
<MoodDimensionTrends dimensions={dimensionSummaries} />
</SectionCard>
)}
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard
title={t("insights.mood.distributionTitle")}
icon={BarChart3}
>
<MoodDistributionChart distribution={data.distribution} />
</SectionCard>
<SectionCard
title={t("insights.mood.weekdayTitle")}
icon={CalendarRange}
>
<MoodWeekdayChart weekday={data.weekday} />
</SectionCard>
</div>
{data.timeOfDay.reliable && (
<SectionCard title={t("insights.mood.timeOfDay.title")} icon={Clock}>
<MoodTimeOfDayChart pattern={data.timeOfDay} />
</SectionCard>
)}
{hasStructuredTags && (
<SectionCard title={t("insights.mood.structuredTagsTitle")} icon={Tags}>
<MoodStructuredTagBreakdown tags={data.structuredTags} />
</SectionCard>
)}
{hasTags && (
<SectionCard title={t("insights.mood.tagsTitle")} icon={Tag}>
<MoodTagBreakdown tags={data.tags} />
</SectionCard>
)}
{hasInfluence && (
<SectionCard
title={t("insights.mood.influence.title")}
icon={TrendingUp}
>
<MoodTagInfluence rows={influenceRows} />
</SectionCard>
)}
{hasCrosstab && (
<SectionCard title={t("insights.mood.crosstab.title")} icon={Grid3x3}>
<MoodTagMetricCrosstab rows={crosstabRows} />
</SectionCard>
)}
{hasFactorCrosstab && (
<SectionCard
title={t("insights.mood.factorCrosstab.title")}
icon={Gauge}
>
<MoodFactorMetricCrosstab rows={factorCrosstabRows} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.correlationsTitle")} icon={Link2}>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.correlationsDescription")}
</p>
<MoodCorrelationCards
sleep={data.correlations.sleep}
steps={data.correlations.steps}
pulse={data.correlations.pulse}
weight={data.correlations.weight}
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
/>
</SectionCard>
</div>
);
// The rest region is one deferred subtree now: everything in it sits below
// the fold, and the payload rides in as a prop so the deferred module cannot
// draw a different snapshot of the aggregate from the regions above it.
return <MoodInsightsBreakdowns data={data} />;
}
@@ -0,0 +1,96 @@
"use client";
/**
* The payload shape and the card frame both mood-insights modules need.
*
* Extracted when the breakdown cluster moved behind `next/dynamic`: the
* eager module (heatmap + assessment) and the deferred one (every breakdown)
* both describe the same `/api/mood/insights` response and both draw the same
* card, and a second copy of either would be the drift this file exists to
* prevent. Nothing heavy lives here — the type is erased at build time and the
* frame is a `Card` with a `TileHeader` in it — so importing it from the eager
* module costs the first-load graph nothing.
*/
import type { ComponentType } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import type { MoodDistributionRow } from "./mood-distribution-chart";
import type { MoodDimensionSummaryData } from "./mood-dimension-trends";
import type { MoodWeekdayRow } from "./mood-weekday-chart";
import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart";
import type { MoodTagRow } from "./mood-tag-breakdown";
import type { MoodMetricCorrelationData } from "./mood-correlation-cards";
import type { MoodStructuredTagRow } from "./mood-structured-tag-breakdown";
import type { MoodNarrativeItem } from "./mood-narrative-feed";
import type { MoodStabilityData } from "./mood-stability-tile";
import type { MoodTagInfluenceRow } from "./mood-tag-influence";
import type { MoodBetterDayFactor } from "./mood-better-days";
import type { MoodTagMetricCrosstabRow } from "./mood-tag-metric-crosstab";
import type { MoodFactorMetricCrosstabRow } from "./mood-factor-metric-crosstab";
import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
export interface MoodInsightsResponse {
summary: {
totalEntries: number;
inTargetPct: number | null;
};
heatmap: {
windowDays: number;
cells: Array<{ date: string; score: number; samples: number }>;
};
distribution: MoodDistributionRow[];
// Optional only to tolerate a stale pre-v1.37 cached payload during a
// rollout; the live endpoint always populates it.
dimensions?: MoodDimensionSummaryData[];
weekday: MoodWeekdayRow[];
timeOfDay: MoodTimeOfDayPattern;
stability: MoodStabilityData | null;
tags: MoodTagRow[];
structuredTags: MoodStructuredTagRow[];
// Optional only to tolerate a stale pre-v1.11.5 cached payload during a
// rollout; the live endpoint always populates both.
tagInfluence?: {
flat: MoodTagInfluenceRow[];
structured: MoodTagInfluenceRow[];
};
betterDays?: MoodBetterDayFactor[];
// Optional only to tolerate a stale pre-v1.12.0 cached payload during a
// rollout; the live endpoint always populates it.
tagMetricCrosstab?: MoodTagMetricCrosstabRow[];
/** v1.38 — context value against the day's mood, counts included. */
contextComparison?: ContextComparisonRow[];
// Optional only to tolerate a stale pre-v1.14.0 cached payload during a
// rollout; the live endpoint always populates it.
factorCrosstab?: MoodFactorMetricCrosstabRow[];
narratives: MoodNarrativeItem[];
correlations: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
weight: MoodMetricCorrelationData;
bloodPressureSystolic: MoodMetricCorrelationData;
};
}
export function SectionCard({
title,
icon,
children,
}: {
title: string;
// v1.12.6 — every mood section header now routes through `TileHeader`
// (icon + white heading) so the subpage reads as one card language.
icon: ComponentType<{ className?: string }>;
children: React.ReactNode;
}) {
return (
<Card>
<CardHeader>
<TileHeader icon={icon} title={title} />
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
applyOptimisticSlotMark,
slotMarkRequestBody,
complianceFromLedger,
formatSlotDelta,
groupLedgerByDay,
@@ -195,3 +196,23 @@ describe("applyOptimisticSlotMark", () => {
expect(next.rows[0].status).toBe("taken_late");
});
});
describe("slotMarkRequestBody", () => {
const row = { at: "2026-08-04T07:00:00.000Z" };
it("anchors takenAt on the slot instant, not on now", () => {
const body = slotMarkRequestBody(row, "taken");
expect(body).toEqual({
skipped: false,
scheduledFor: "2026-08-04T07:00:00.000Z",
takenAt: "2026-08-04T07:00:00.000Z",
});
});
it("keeps the skipped arm free of takenAt", () => {
expect(slotMarkRequestBody(row, "skipped")).toEqual({
skipped: true,
scheduledFor: "2026-08-04T07:00:00.000Z",
});
});
});
@@ -204,3 +204,18 @@ export function applyOptimisticSlotMark(
});
return { ...payload, rows };
}
/**
* Request body for marking a ledger slot from the Verlauf. The taken arm
* anchors `takenAt` on the slot instant: marking Tuesday 09:00 from a
* Thursday evening records a dose taken Tuesday 09:00, which is the claim
* the person is making. The optimistic paint above already promised this.
*/
export function slotMarkRequestBody(
row: Pick<LedgerRow, "at">,
action: "taken" | "skipped",
): Record<string, unknown> {
return action === "skipped"
? { skipped: true, scheduledFor: row.at }
: { skipped: false, scheduledFor: row.at, takenAt: row.at };
}
@@ -106,6 +106,7 @@ import {
formatSlotDelta,
groupLedgerByDay,
isSlotActionable,
slotMarkRequestBody,
type LedgerPayload,
type LedgerRow,
} from "@/components/medications/dose-history-ledger-compute";
@@ -273,18 +274,13 @@ export function DoseHistoryLedger({
try {
// Pin the displayed slot so the server marks THAT dose (the canonical
// slot upsert), not a now-snap to the nearest one. The POST returns
// the created event; its id drives the Undo affordance (the same
// soft-delete route the cards use).
// slot upsert), not a now-snap to the nearest one — and anchor
// `takenAt` on the same instant, matching the optimistic paint. The
// POST returns the created event; its id drives the Undo affordance
// (the same soft-delete route the cards use).
const created = await apiPost<{ id?: string } | undefined>(
`/api/medications/${medicationId}/intake`,
action === "skipped"
? { skipped: true, scheduledFor: row.at }
: {
skipped: false,
scheduledFor: row.at,
takenAt: new Date().toISOString(),
},
slotMarkRequestBody(row, action),
);
const eventId: string | undefined = created?.id;
toast.success(
@@ -0,0 +1,108 @@
/**
* Which zone a linked-day read is made in, and which surface decides it.
*
* The finding this file answers was not a wrong number — it was a doc comment
* that said "the detail view passes the entry's stored tz" beside code that
* always sent the browser's. Nothing was red, because nothing asserted the
* claim. Two ends, so two kinds of assertion:
*
* * the resolution itself, on the pure helper, at both branches;
* * the two call sites, structurally, because the resolution being right is
* worth nothing if the edit dialog never hands it the entry's zone.
*
* Both matchers assert a non-zero match count. An empty match set fails rather
* than passing quietly, which is how a guard stays a guard after somebody
* renames the thing it greps for.
*
* Mutation check: drop the `editing?.tz` argument at the edit call site and
* the call-site row goes red; make the helper prefer its browser argument and
* the resolution rows go red.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { linkedDayRequest } from "../use-linked-day-context";
const HERE = join(__dirname, "..");
const LIST = readFileSync(join(HERE, "mood-list.tsx"), "utf8");
const FORM = readFileSync(join(HERE, "mood-form.tsx"), "utf8");
/** The arguments of every `useLinkedDayContext(...)` call in a source file. */
function callArguments(source: string): string[] {
const calls: string[] = [];
const needle = "useLinkedDayContext(";
let at = source.indexOf(needle);
while (at !== -1) {
// Only a CALL, never the import line that names the same identifier.
const open = at + needle.length - 1;
let depth = 0;
let end = open;
for (let i = open; i < source.length; i++) {
if (source[i] === "(") depth++;
else if (source[i] === ")") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
calls.push(source.slice(open + 1, end));
at = source.indexOf(needle, end);
}
return calls;
}
describe("linked-day timezone", () => {
it("prefers a stored zone over the browser's", () => {
const { tz, url } = linkedDayRequest(
"2026-06-11",
"America/New_York",
"Europe/Berlin",
);
expect(tz).toBe("America/New_York");
expect(url).toContain("tz=America%2FNew_York");
expect(url).toContain("date=2026-06-11");
});
it("falls back to the browser's zone when the row has none", () => {
// A legacy row (`tz IS NULL`) and an entry that does not exist yet are the
// same case here. Not Europe/Berlin: the server owns the legacy rule and a
// guess sent from the client would override the one place it is written.
expect(linkedDayRequest("2026-06-11", null, "Europe/Berlin").tz).toBe(
"Europe/Berlin",
);
expect(linkedDayRequest("2026-06-11", "", "Europe/Berlin").tz).toBe(
"Europe/Berlin",
);
expect(linkedDayRequest("2026-06-11", undefined, "Asia/Tokyo").tz).toBe(
"Asia/Tokyo",
);
});
it("has the edit dialog hand over the entry's own zone", () => {
const calls = callArguments(LIST);
expect(
calls.length,
"no useLinkedDayContext call found in mood-list.tsx — this matcher proves nothing",
).toBe(1);
expect(
calls[0],
"the edit dialog reads the linked day under the browser's zone; an entry logged abroad would be looked up on the wrong day",
).toContain("editing?.tz");
});
it("leaves the capture sheet on the browser's zone", () => {
const calls = callArguments(FORM);
expect(
calls.length,
"no useLinkedDayContext call found in mood-form.tsx — this matcher proves nothing",
).toBe(1);
// A new entry has no stored zone to pass, so the third argument is absent
// on purpose. Passing something here would be inventing a zone for a row
// that has not chosen one.
expect(calls[0]).not.toContain("tz");
});
});
+701
View File
@@ -0,0 +1,701 @@
"use client";
/**
* The four day-context sections, and the read-only block beside them.
*
* One component for both mood surfaces — the create sheet and the list's edit
* dialog — for the same reason the level-A sliders share one: what one writes
* the other has to be able to show, and a value somebody set on Monday and
* cannot correct on Tuesday is worse than one they were never offered.
*
* Every section is closed by default and renders no inputs while closed, so an
* entry is still one tap and Save. The count badge on a closed section says
* how much is in it, so nothing is hidden silently.
*
* Two copy rules hold throughout, and both are load-bearing rather than
* stylistic. Labels state facts and never judgements: "worked a regular day"
* is a fact and "worked too much" is a verdict on somebody's life. And the
* linked block is read-only — it shows what the sleep, activity, vitals and
* illness modules already hold, names where each figure comes from, and offers
* a way in to change it there. It is never an input here, because the moment
* it were, the same night would have two homes.
*/
import { useMemo } from "react";
import {
Activity,
Briefcase,
CalendarClock,
Link2,
Palmtree,
Users,
} from "lucide-react";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { SliderField } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
CONTEXT_MINUTES_MAX,
CONTEXT_MINUTES_MIN,
CONTEXT_RATING_MAX,
CONTEXT_RATING_MIN,
CONTEXT_VALENCE_MAX,
CONTEXT_VALENCE_MIN,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
contextSectionLabelKey,
contextValueLabelKey,
} from "@/lib/mood/context-vocabulary";
import type { LinkedDayContext, LinkedFigure } from "@/lib/mood/linked-context";
const CONTEXT_NOTE_MAX_LENGTH = 500;
/** The context as the form holds it. `null` everywhere means unanswered. */
export interface MoodContextState {
workStatus: string | null;
workMinutes: number | null;
overtimeMinutes: number | null;
workLoad: number | null;
workSatisfaction: number | null;
contactCircles: string[];
contactForm: string | null;
contactExtent: string | null;
contactQuality: number | null;
contactSupport: number | null;
leisureCategories: string[];
leisureMinutes: number | null;
leisureJoy: number | null;
leisureRecovery: number | null;
eventType: string | null;
eventValence: number | null;
/** Local `datetime-local` value, or "" for unanswered. */
eventAt: string;
note: string;
}
export const EMPTY_MOOD_CONTEXT: MoodContextState = {
workStatus: null,
workMinutes: null,
overtimeMinutes: null,
workLoad: null,
workSatisfaction: null,
contactCircles: [],
contactForm: null,
contactExtent: null,
contactQuality: null,
contactSupport: null,
leisureCategories: [],
leisureMinutes: null,
leisureJoy: null,
leisureRecovery: null,
eventType: null,
eventValence: null,
eventAt: "",
note: "",
};
/** Which state fields each section owns, so a badge counts its own section. */
const SECTION_FIELDS = {
work: [
"workStatus",
"workMinutes",
"overtimeMinutes",
"workLoad",
"workSatisfaction",
],
contacts: [
"contactCircles",
"contactForm",
"contactExtent",
"contactQuality",
"contactSupport",
],
leisure: [
"leisureCategories",
"leisureMinutes",
"leisureJoy",
"leisureRecovery",
],
events: ["eventType", "eventValence", "eventAt", "note"],
} as const satisfies Record<string, ReadonlyArray<keyof MoodContextState>>;
function isAnswered(value: MoodContextState[keyof MoodContextState]): boolean {
if (value === null) return false;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "string") return value.trim() !== "";
return true;
}
/** How many answers a section holds — the collapsed section's badge. */
export function contextSectionCount(
state: MoodContextState,
section: keyof typeof SECTION_FIELDS,
): number {
return SECTION_FIELDS[section].filter((field) => isAnswered(state[field]))
.length;
}
/** Does the whole context say anything at all? */
export function contextIsEmpty(state: MoodContextState): boolean {
return (
Object.keys(SECTION_FIELDS) as Array<keyof typeof SECTION_FIELDS>
).every((section) => contextSectionCount(state, section) === 0);
}
/**
* The context as the API takes it.
*
* `null` when nothing was answered, which is how the write path is told to
* store no row (and to remove one that is there). Not `undefined`: on the edit
* path an omitted key means "leave it alone", and a person who cleared every
* section means the opposite of that.
*/
export function moodContextPayload(
state: MoodContextState,
): Record<string, unknown> | null {
if (contextIsEmpty(state)) return null;
return {
workStatus: state.workStatus,
workMinutes: state.workMinutes,
overtimeMinutes: state.overtimeMinutes,
workLoad: state.workLoad,
workSatisfaction: state.workSatisfaction,
contactCircles: state.contactCircles,
contactForm: state.contactForm,
contactExtent: state.contactExtent,
contactQuality: state.contactQuality,
contactSupport: state.contactSupport,
leisureCategories: state.leisureCategories,
leisureMinutes: state.leisureMinutes,
leisureJoy: state.leisureJoy,
leisureRecovery: state.leisureRecovery,
eventType: state.eventType,
eventValence: state.eventValence,
eventAt: state.eventAt ? new Date(state.eventAt).toISOString() : null,
note: state.note.trim() === "" ? null : state.note.trim(),
};
}
/** Seed the form from an entry being edited. */
export function moodContextFromEntry(
context:
| (Partial<Omit<MoodContextState, "eventAt" | "note">> & {
eventAt?: string | null;
note?: string | null;
})
| null
| undefined,
): MoodContextState {
if (!context) return EMPTY_MOOD_CONTEXT;
return {
...EMPTY_MOOD_CONTEXT,
...context,
contactCircles: context.contactCircles ?? [],
leisureCategories: context.leisureCategories ?? [],
// The wire carries an ISO instant; the control wants a local wall clock.
eventAt: context.eventAt ? toLocalInputValue(context.eventAt) : "",
note: context.note ?? "",
};
}
function toLocalInputValue(iso: string): string {
const at = new Date(iso);
const local = new Date(at.getTime() - at.getTimezoneOffset() * 60 * 1000);
return local.toISOString().slice(0, 16);
}
/** A selectable chip. Tapping the selected one again takes the answer back. */
function ChoiceChip({
label,
selected,
onClick,
}: {
label: string;
selected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
data-slot="mood-context-choice"
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
selected
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
{label}
</button>
);
}
function ChoiceRow({
label,
field,
keys,
value,
onChange,
}: {
label: string;
field: string;
keys: readonly string[];
value: string | null;
onChange: (next: string | null) => void;
}) {
const { t } = useTranslations();
return (
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-normal">
{label}
</Label>
<div className="flex flex-wrap gap-1.5">
{keys.map((key) => (
<ChoiceChip
key={key}
label={t(contextValueLabelKey(field, key))}
selected={value === key}
onClick={() => onChange(value === key ? null : key)}
/>
))}
</div>
</div>
);
}
function MultiChoiceRow({
label,
field,
keys,
value,
onChange,
}: {
label: string;
field: string;
keys: readonly string[];
value: string[];
onChange: (next: string[]) => void;
}) {
const { t } = useTranslations();
return (
<div className="space-y-2">
<Label className="text-muted-foreground text-xs font-normal">
{label}
</Label>
<div className="flex flex-wrap gap-1.5">
{keys.map((key) => (
<ChoiceChip
key={key}
label={t(contextValueLabelKey(field, key))}
selected={value.includes(key)}
onClick={() =>
onChange(
value.includes(key)
? value.filter((k) => k !== key)
: [...value, key],
)
}
/>
))}
</div>
</div>
);
}
function MinutesField({
id,
label,
value,
onChange,
}: {
id: string;
label: string;
value: number | null;
onChange: (next: number | null) => void;
}) {
const { t } = useTranslations();
return (
<FieldGroup htmlFor={id} label={label}>
<Input
id={id}
type="number"
inputMode="numeric"
min={CONTEXT_MINUTES_MIN}
max={CONTEXT_MINUTES_MAX}
step={5}
value={value === null ? "" : String(value)}
placeholder={t("mood.dayContext.minutesPlaceholder")}
onChange={(e) => {
const raw = e.target.value;
// An emptied field is an unanswered field, not a zero. Zero minutes
// is a real answer somebody can still type.
onChange(raw === "" ? null : Number(raw));
}}
/>
</FieldGroup>
);
}
/** One 0-10 rating with its two anchors, resolved from the vocabulary. */
function ContextRating({
field,
value,
onChange,
}: {
field: string;
value: number | null;
onChange: (next: number | null) => void;
}) {
const { t } = useTranslations();
const low = t(`mood.context.rating.${field}.low`);
const high = t(`mood.context.rating.${field}.high`);
return (
<SliderField
label={t(contextValueLabelKey("field", field))}
value={value}
min={CONTEXT_RATING_MIN}
max={CONTEXT_RATING_MAX}
step={1}
lowAnchor={low}
highAnchor={high}
emptyLabel={t("mood.dimensionNotAnswered")}
valueText={
value === null
? undefined
: t("mood.dimensionValueText", {
value,
max: CONTEXT_RATING_MAX,
low,
high,
})
}
clearLabel={t("mood.dimensionClear")}
onClear={() => onChange(null)}
onValueChange={onChange}
/>
);
}
interface MoodContextSectionsProps {
value: MoodContextState;
onChange: (next: MoodContextState) => void;
}
export function MoodContextSections({
value,
onChange,
}: MoodContextSectionsProps) {
const { t } = useTranslations();
const set = <K extends keyof MoodContextState>(
key: K,
next: MoodContextState[K],
) => onChange({ ...value, [key]: next });
return (
<>
<SheetSection
title={t(contextSectionLabelKey("work"))}
icon={<Briefcase />}
summary={
<SheetSectionCount count={contextSectionCount(value, "work")} />
}
>
<div className="space-y-4" data-slot="mood-context-work">
<ChoiceRow
label={t(contextValueLabelKey("field", "workStatus"))}
field="workStatus"
keys={WORK_STATUS_KEYS}
value={value.workStatus}
onChange={(next) => set("workStatus", next)}
/>
<MinutesField
id="mood-context-work-minutes"
label={t(contextValueLabelKey("field", "workMinutes"))}
value={value.workMinutes}
onChange={(next) => set("workMinutes", next)}
/>
<MinutesField
id="mood-context-overtime-minutes"
label={t(contextValueLabelKey("field", "overtimeMinutes"))}
value={value.overtimeMinutes}
onChange={(next) => set("overtimeMinutes", next)}
/>
<ContextRating
field="workLoad"
value={value.workLoad}
onChange={(next) => set("workLoad", next)}
/>
<ContextRating
field="workSatisfaction"
value={value.workSatisfaction}
onChange={(next) => set("workSatisfaction", next)}
/>
</div>
</SheetSection>
<SheetSection
title={t(contextSectionLabelKey("contacts"))}
icon={<Users />}
summary={
<SheetSectionCount count={contextSectionCount(value, "contacts")} />
}
>
<div className="space-y-4" data-slot="mood-context-contacts">
<p className="text-muted-foreground text-xs">
{t("mood.dayContext.contactsHelp")}
</p>
<MultiChoiceRow
label={t(contextValueLabelKey("field", "contactCircles"))}
field="contactCircles"
keys={CONTACT_CIRCLE_KEYS}
value={value.contactCircles}
onChange={(next) => set("contactCircles", next)}
/>
<ChoiceRow
label={t(contextValueLabelKey("field", "contactForm"))}
field="contactForm"
keys={CONTACT_FORM_KEYS}
value={value.contactForm}
onChange={(next) => set("contactForm", next)}
/>
<ChoiceRow
label={t(contextValueLabelKey("field", "contactExtent"))}
field="contactExtent"
keys={CONTACT_EXTENT_KEYS}
value={value.contactExtent}
onChange={(next) => set("contactExtent", next)}
/>
<ContextRating
field="contactQuality"
value={value.contactQuality}
onChange={(next) => set("contactQuality", next)}
/>
<ContextRating
field="contactSupport"
value={value.contactSupport}
onChange={(next) => set("contactSupport", next)}
/>
</div>
</SheetSection>
<SheetSection
title={t(contextSectionLabelKey("leisure"))}
icon={<Palmtree />}
summary={
<SheetSectionCount count={contextSectionCount(value, "leisure")} />
}
>
<div className="space-y-4" data-slot="mood-context-leisure">
<MultiChoiceRow
label={t(contextValueLabelKey("field", "leisureCategories"))}
field="leisureCategories"
keys={LEISURE_CATEGORY_KEYS}
value={value.leisureCategories}
onChange={(next) => set("leisureCategories", next)}
/>
<MinutesField
id="mood-context-leisure-minutes"
label={t(contextValueLabelKey("field", "leisureMinutes"))}
value={value.leisureMinutes}
onChange={(next) => set("leisureMinutes", next)}
/>
<ContextRating
field="leisureJoy"
value={value.leisureJoy}
onChange={(next) => set("leisureJoy", next)}
/>
<ContextRating
field="leisureRecovery"
value={value.leisureRecovery}
onChange={(next) => set("leisureRecovery", next)}
/>
</div>
</SheetSection>
<SheetSection
title={t(contextSectionLabelKey("events"))}
icon={<CalendarClock />}
summary={
<SheetSectionCount count={contextSectionCount(value, "events")} />
}
>
<div className="space-y-4" data-slot="mood-context-events">
<ChoiceRow
label={t(contextValueLabelKey("field", "eventType"))}
field="eventType"
keys={EVENT_TYPE_KEYS}
value={value.eventType}
onChange={(next) => set("eventType", next)}
/>
<SliderField
label={t(contextValueLabelKey("field", "eventValence"))}
value={value.eventValence}
min={CONTEXT_VALENCE_MIN}
max={CONTEXT_VALENCE_MAX}
step={1}
lowAnchor={t("mood.context.rating.eventValence.low")}
highAnchor={t("mood.context.rating.eventValence.high")}
emptyLabel={t("mood.dimensionNotAnswered")}
clearLabel={t("mood.dimensionClear")}
onClear={() => set("eventValence", null)}
onValueChange={(next) => set("eventValence", next)}
/>
<FieldGroup
htmlFor="mood-context-event-at"
label={t(contextValueLabelKey("field", "eventAt"))}
>
<DateTimeField
id="mood-context-event-at"
value={value.eventAt}
onChange={(next) => set("eventAt", next)}
/>
</FieldGroup>
<FieldGroup
htmlFor="mood-context-note"
label={t(contextValueLabelKey("field", "notes"))}
>
<Textarea
id="mood-context-note"
value={value.note}
onChange={(e) => set("note", e.target.value)}
placeholder={t("mood.dayContext.notePlaceholder")}
maxLength={CONTEXT_NOTE_MAX_LENGTH}
rows={3}
/>
</FieldGroup>
</div>
</SheetSection>
</>
);
}
function formatFigure(
figure: LinkedFigure | undefined,
notRecorded: string,
): string {
if (!figure || !figure.present) return notRecorded;
return `${figure.value} ${figure.unit}`;
}
/**
* The read-only block: what the other modules already hold for this day.
*
* Every row names where its number comes from and offers a way in to change it
* there. A block whose module is switched off is not rendered at all — not
* zeroed, not greyed out — because turning a module off is a statement about
* what somebody wants to see.
*/
export function MoodLinkedDaySection({
linked,
}: {
linked: LinkedDayContext | null;
}) {
const { t } = useTranslations();
const notRecorded = t("mood.dayContext.notRecorded");
const rows = useMemo(() => {
if (!linked) return [];
const out: Array<{ label: string; value: string; href: string }> = [];
if (linked.sleep.available) {
out.push({
label: t("mood.dayContext.linkedSleep"),
value: formatFigure(linked.sleep.asleep, notRecorded),
href: "/insights/sleep",
});
out.push({
label: t("mood.dayContext.linkedInBed"),
value: formatFigure(linked.sleep.inBed, notRecorded),
href: "/insights/sleep",
});
}
if (linked.activity.available) {
out.push({
label: t("mood.dayContext.linkedSteps"),
value: formatFigure(linked.activity.steps, notRecorded),
href: "/insights/steps",
});
out.push({
label: t("mood.dayContext.linkedActiveEnergy"),
value: formatFigure(linked.activity.activeEnergy, notRecorded),
href: "/insights/steps",
});
}
if (linked.vitals.available) {
out.push({
label: t("mood.dayContext.linkedRestingHeartRate"),
value: formatFigure(linked.vitals.restingHeartRate, notRecorded),
href: "/measurements",
});
out.push({
label: t("mood.dayContext.linkedHrv"),
value: formatFigure(linked.vitals.heartRateVariability, notRecorded),
href: "/measurements",
});
}
return out;
}, [linked, notRecorded, t]);
if (!linked) return null;
const bodyLine = linked.body.available
? linked.body.logged
? t("mood.dayContext.bodyLogged", {
count: String(linked.body.symptoms.length),
})
: t("mood.dayContext.bodyNotLogged")
: null;
return (
<SheetSection
title={t("mood.dayContext.linkedTitle")}
icon={<Link2 />}
summary={<SheetSectionCount count={rows.length} />}
>
<div className="space-y-3" data-slot="mood-linked-day">
<p className="text-muted-foreground text-xs">
{t("mood.dayContext.linkedHelp")}
</p>
<dl className="space-y-1.5">
{rows.map((row) => (
<div
key={row.label}
className="flex items-center justify-between gap-3"
>
<dt className="text-muted-foreground text-xs">{row.label}</dt>
<dd className="text-foreground text-sm tabular-nums">
{row.value}
</dd>
</div>
))}
</dl>
{bodyLine ? (
<div className="border-border/60 flex items-center justify-between gap-3 border-t pt-3">
<span className="text-muted-foreground text-xs">
<Activity className="mr-1 inline h-3 w-3" aria-hidden="true" />
{bodyLine}
</span>
<a
href={
linked.body.available && linked.body.episodeId
? `/illness/${linked.body.episodeId}`
: "/illness"
}
className="text-primary text-xs underline underline-offset-2"
>
{t("mood.dayContext.openIllness")}
</a>
</div>
) : null}
</div>
</SheetSection>
);
}
+42
View File
@@ -52,6 +52,15 @@ import {
seedA1FromMood,
type LevelAState,
} from "./mood-level-a-fields";
import {
EMPTY_MOOD_CONTEXT,
MoodContextSections,
MoodLinkedDaySection,
contextIsEmpty,
moodContextPayload,
type MoodContextState,
} from "./mood-context-fields";
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
import { MoodQuickTags } from "./mood-quick-tags";
import { useRecentTags } from "./recent-tags";
import { moodFaceIcon } from "./mood-tag-icons";
@@ -128,6 +137,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
const [levelA, setLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
const [a1Touched, setA1Touched] = useState(false);
const [levelAOpen, setLevelAOpen] = useState(false);
// v1.38 — the day's context. Four closed sections; nothing here is required
// and an entry that never opens one stores no context row at all.
const [context, setContext] = useState<MoodContextState>(EMPTY_MOOD_CONTEXT);
const [tagsInput, setTagsInput] = useState("");
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
const [tagKeys, setTagKeys] = useState<string[]>([]);
@@ -154,6 +166,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// A level-A value the user set counts as typed input: Reset must ask
// before it discards an answer that took five slider moves.
Object.values(levelA).some((v) => v !== null) ||
// A filled context section is typed input too: Reset asks before it
// discards a day somebody described.
!contextIsEmpty(context) ||
note.trim() !== "";
// v1.37 — picking a face suggests a pleasantness value; the suggestion
@@ -184,6 +199,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
setLevelA(EMPTY_LEVEL_A);
setA1Touched(false);
setLevelAOpen(false);
setContext(EMPTY_MOOD_CONTEXT);
setTagsInput("");
setTagKeys([]);
setNote("");
@@ -225,6 +241,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// v1.37 — only the values somebody answered. An untouched slider
// sends nothing, and the server writes NULL rather than a middle.
...levelACreatePayload(levelA),
// v1.38 — omitted when nothing was answered, so the create stores no
// context row rather than a row of NULLs.
...(contextIsEmpty(context)
? {}
: { context: moodContextPayload(context) }),
moodLoggedAt: timestamp,
});
@@ -295,6 +316,16 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// primary input and a fast log is a single tap + Save.
const moodPicked = mood !== "";
// v1.38 — what the other modules already hold for the day being logged.
// Keyed on the timestamp field, so moving the entry to yesterday shows
// yesterday's night. Only fetched once a face is picked: an unopened sheet
// must not cost four modules a query.
// No third argument, and deliberately: this entry does not exist yet, so it
// has no stored zone. It will be anchored to the browser's, which is the
// zone the hook falls back to — the figures shown here are the figures the
// saved row will sit beside.
const linked = useLinkedDayContext(dayOfLocalInput(moodLoggedAt), moodPicked);
// v1.17.0 — the "Note & details" badge counts the free-text tags entered
// plus the note (when present), so a collapsed section communicates what
// it holds.
@@ -420,6 +451,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
onOpenChange={setLevelAOpen}
/>
{/* v1.38 — the day around the entry: work, contacts, leisure and a
notable event. Four closed sections; opening none of them is a
complete entry. */}
<MoodContextSections value={context} onChange={setContext} />
{/* v1.38 — read-only, and read from the modules that own it. Sleep,
activity and vitals are never asked for a second time here; the
body line links into the illness journal instead of growing a
second symptom form. */}
<MoodLinkedDaySection linked={linked} />
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
and the note field. The badge counts whichever of these carry
input. */}
+61
View File
@@ -94,6 +94,16 @@ import {
levelAUpdatePayload,
type LevelAState,
} from "./mood-level-a-fields";
import type { MoodContextWire } from "@/lib/mood/context";
import {
EMPTY_MOOD_CONTEXT,
MoodContextSections,
MoodLinkedDaySection,
moodContextFromEntry,
moodContextPayload,
type MoodContextState,
} from "./mood-context-fields";
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
@@ -118,6 +128,14 @@ interface MoodEntry {
a4?: number | null;
a5?: number | null;
note: string | null;
// v1.38 — the day context, null on entries that carry none.
context: MoodContextWire | null;
/**
* The IANA zone this entry's `date` is anchored to, or null on a legacy row.
* Read by the linked-day block so an entry logged in another country is
* looked up under ITS day rather than under the corrector's.
*/
tz: string | null;
source: string;
moodLoggedAt: string;
}
@@ -201,6 +219,12 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
// v1.37 — the five level-A values on the edit path. Seeded from the entry,
// so what the capture sheet wrote is visible and changeable here.
const [editLevelA, setEditLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
// v1.38 — the day context on the edit path, seeded from the entry so what
// the capture sheet wrote is visible and correctable here. A value that can
// be written on one surface and not corrected on another is worse than one
// nobody was offered.
const [editContext, setEditContext] =
useState<MoodContextState>(EMPTY_MOOD_CONTEXT);
const [editNote, setEditNote] = useState("");
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
const [editSeed, setEditSeed] = useState<{
@@ -209,6 +233,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: string[];
ratedFactors: RatedFactor[];
levelA: LevelAState;
context: MoodContextState;
note: string;
moodLoggedAt: string;
}>({
@@ -217,6 +242,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: [],
ratedFactors: [],
levelA: EMPTY_LEVEL_A,
context: EMPTY_MOOD_CONTEXT,
note: "",
moodLoggedAt: "",
});
@@ -410,6 +436,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys,
ratedFactors,
levelA,
context,
note,
moodLoggedAt,
}: {
@@ -419,6 +446,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: string[];
ratedFactors: RatedFactor[];
levelA: LevelAState;
context: MoodContextState;
note: string | null;
moodLoggedAt: string;
}) => {
@@ -431,6 +459,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
// user cleared has to travel as an explicit null or the old answer
// survives an edit that visibly removed it.
...levelAUpdatePayload(levelA),
// Always stated on this path, and `null` when every section is empty:
// the dialog shows the whole context, so an omission here would leave
// a context the person just cleared sitting on the row.
context: moodContextPayload(context),
note,
moodLoggedAt,
});
@@ -446,6 +478,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
a.key.localeCompare(b.key),
),
levelA: editLevelA,
context: editContext,
note: editNote,
moodLoggedAt: editMoodLoggedAt,
});
@@ -465,12 +498,26 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
a.key.localeCompare(b.key),
),
levelA: editLevelA,
context: editContext,
note: editNote,
moodLoggedAt: editMoodLoggedAt,
},
blocked: updateMutation.isPending || deleteMutation.isPending,
});
// v1.38 — the linked figures for the entry being edited, read for its own
// local day. Only while a dialog is open: a list of fifty rows must not cost
// fifty module reads.
const editLinked = useLinkedDayContext(
dayOfLocalInput(editMoodLoggedAt),
editing !== null,
// The entry's OWN zone, not the browser's. An entry logged abroad decided
// which day it belongs to when it was written; re-reading that day under
// whichever zone the correction is being made from would shift the night
// beside it by hours with nothing on screen saying so.
editing?.tz ?? null,
);
const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0;
function startEdit(entry: MoodEntry) {
@@ -482,6 +529,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
a.key.localeCompare(b.key),
),
levelA: levelAFromEntry(entry),
context: moodContextFromEntry(entry.context),
note: entry.note ?? "",
moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt),
};
@@ -491,6 +539,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
setEditTagKeys(seed.tagKeys);
setEditRatedFactors(seed.ratedFactors);
setEditLevelA(seed.levelA);
setEditContext(seed.context);
setEditNote(seed.note);
setEditMoodLoggedAt(seed.moodLoggedAt);
setEditSeed(seed);
@@ -544,6 +593,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagKeys: editTagKeys,
ratedFactors: editRatedFactors,
levelA: editLevelA,
context: editContext,
note: trimmedNote.length > 0 ? trimmedNote : null,
moodLoggedAt: measuredDate.toISOString(),
});
@@ -1044,6 +1094,17 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
set. */}
<MoodLevelASection value={editLevelA} onChange={setEditLevelA} />
{/* v1.38 — the day context, seeded from the entry. */}
<MoodContextSections
value={editContext}
onChange={setEditContext}
/>
{/* v1.38 — read-only, from the modules that own it. Resolved for
the entry's own day, so an entry moved to another date shows
that day's figures. */}
<MoodLinkedDaySection linked={editLinked} />
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<Label htmlFor="edit-mood-note">
@@ -0,0 +1,100 @@
"use client";
/**
* Read what the other modules already hold for one local day.
*
* A hook rather than a prop drilled from a page, because both mood surfaces
* need it and both know only the wall-clock value in their timestamp field.
*
* **Which zone that day is read in depends on which surface is asking, and the
* two are genuinely different questions.** The capture sheet is about to write
* a new entry, and that entry will be anchored to the zone the browser is in,
* so the browser's zone is the right one and the figures it shows are the
* figures the saved row will sit beside. The edit dialog is looking at an
* entry that already decided which day it belongs to and stored the zone it
* decided in: reading that entry's day under the corrector's browser zone
* would show a night from an entry logged in another country shifted by
* several hours, and nothing on the screen would say so. So the caller passes
* the entry's stored `tz` and the hook does not guess.
*
* The query key carries both the day and the zone. One key for both would let
* whichever answer arrived first serve the other — the same-key /
* different-shape cache poisoning the key factory exists to prevent, in its
* quieter same-shape / different-window form. Two surfaces asking about the
* same calendar date under two zones is exactly that case, and it is reachable
* from one screen: the list can hold an entry logged abroad while the sheet
* writes one logged here.
*/
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { LinkedDayContext } from "@/lib/mood/linked-context";
/**
* The browser's own zone — the right answer for an entry that does not exist
* yet, and the wrong one for an entry that already stored its own.
*/
export function browserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
/** `YYYY-MM-DD` for a `datetime-local` value, or today when it is empty. */
export function dayOfLocalInput(value: string): string {
if (value.length >= 10) return value.slice(0, 10);
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000);
return local.toISOString().slice(0, 10);
}
/**
* @param day the local day to read, `YYYY-MM-DD`
* @param enabled whether to fetch at all
* @param timezone the zone `day` is anchored to. Omit ONLY where the day has
* no zone of its own yet — a new entry the capture sheet is composing. An
* entry being edited passes its stored `tz`.
*/
/**
* The zone a linked-day read should use, and the URL that follows from it.
*
* Pure and exported so the decision can be tested without a renderer: the
* whole finding this answers was a doc comment claiming the edit dialog passed
* the entry's zone while the code always sent the browser's, and a claim like
* that is only worth as much as the assertion under it.
*
* A stored zone wins. Absent — a legacy row with `tz IS NULL`, or an entry
* that does not exist yet — falls back to the browser's, which is honest about
* being an assumption and is what the capture sheet means anyway. It does NOT
* fall back to Europe/Berlin: the server owns that legacy rule, and sending a
* guess would override the one place it is written down.
*/
export function linkedDayRequest(
day: string,
storedTimezone: string | null | undefined,
browserTz: string,
): { tz: string; url: string } {
const tz =
storedTimezone && storedTimezone.length > 0 ? storedTimezone : browserTz;
return {
tz,
url: `/api/mood/linked-context?date=${encodeURIComponent(day)}&tz=${encodeURIComponent(tz)}`,
};
}
export function useLinkedDayContext(
day: string,
enabled = true,
timezone?: string | null,
) {
const { tz, url } = linkedDayRequest(day, timezone, browserTimezone());
const query = useQuery({
queryKey: queryKeys.moodLinkedContext(day, tz),
queryFn: () => apiGet<LinkedDayContext>(url),
enabled,
// The figures come from other modules and change on their own schedule; a
// minute of staleness is cheaper than refetching four modules every time
// the sheet re-renders.
staleTime: 60_000,
});
return query.data ?? null;
}
+4
View File
@@ -204,6 +204,10 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// IllnessDayLog / LabResult note columns above; Bytes via the shared codec.
{ model: "MoodEntry", field: "noteEncrypted", kind: "bytes" },
{ model: "Measurement", field: "notesEncrypted", kind: "bytes" },
// v1.38 — the one free-text note on a mood entry's day context. Same codec
// as the entry note beside it; one column for the whole context rather than
// one per section, so there is one rotation entry rather than four.
{ model: "MoodContext", field: "notesEncrypted", kind: "bytes" },
// ───── v1.25 medication free-text notes (Bytes columns) ─────
// Side-effect log note, dose-change titration note, and inventory-item
+4
View File
@@ -71,6 +71,10 @@ export const WIPE_MODELS = [
"MedicationComplianceRollup",
// ── Mood ────────────────────────────────────────────────────────────────
// The day context, before the entry it hangs off: it cascades either way,
// but a parent deleted first leaves the child's own count reporting zero for
// rows that existed, and the confirmation screen quotes those counts.
"MoodContext",
"MoodEntry",
"MoodEntryRollup",
"MoodReminderDispatch",
+2
View File
@@ -80,6 +80,7 @@ export const BACKED_UP_MODELS = [
// ── Mood ──────────────────────────────────────────────────────────────────
"MoodEntry",
"MoodContext",
"MoodEntryTagLink",
"MoodTag",
"MoodTagCategory",
@@ -226,6 +227,7 @@ export const TWO_ENDED_MODELS = [
"MedicationIntakeEvent",
"MedicationSideEffect",
"MoodEntry",
"MoodContext",
"MoodEntryTagLink",
"MoodTag",
"LabResult",
+99
View File
@@ -155,6 +155,96 @@ const MOOD_ENTRY_TAG_LINK_SELECT = {
moodTag: { select: { key: true, kind: true } },
} as const satisfies Prisma.MoodEntryTagLinkSelect;
/**
* Every restorable `MoodContext` scalar.
*
* `moodEntryId` and `userId` are absent for the reasons the exclusion list in
* `mood-backup-completeness.test.ts` states: the context rides inside its
* entry and is re-bound to whatever id that entry got, and the owner is
* re-derived from the restoring account. The two row stamps are absent for the
* third reason there — they describe when the sub-row was written, not when
* the day happened, and the entry already carries the moment that matters.
*
* Named rather than inlined so the guard can compare it field-by-field against
* the model, exactly as it does for the two selects above. This is the whole
* point of the pair: a column added to `MoodContext` without a line here
* fails, and it fails in the release that added it.
*/
const MOOD_CONTEXT_BACKUP_SELECT = {
workStatus: true,
workMinutes: true,
overtimeMinutes: true,
workLoad: true,
workSatisfaction: true,
contactCircles: true,
contactForm: true,
contactExtent: true,
contactQuality: true,
contactSupport: true,
leisureCategories: true,
leisureMinutes: true,
leisureJoy: true,
leisureRecovery: true,
eventType: true,
eventValence: true,
eventAt: true,
notesEncrypted: true,
} as const satisfies Prisma.MoodContextSelect;
type BackupMoodContext = Prisma.MoodContextGetPayload<{
select: typeof MOOD_CONTEXT_BACKUP_SELECT;
}>;
/**
* One context row, in whichever of the two wire shapes the caller asked for.
*
* The split is the same one `Measurement` makes: a disaster-recovery file
* carries the ciphertext verbatim, because the instance that reads it back
* holds the key; a portable export carries the plain text, because the point
* of a portable file is that somebody can read it. Every other column rides in
* both, since none of them is a storage detail — they are all answers somebody
* gave.
*
* The parameter is spelled `moodContext` rather than `c`, deliberately: the
* completeness guard proves a selected column reaches a payload by matching
* `moodContext.<column>`, and a one-letter parameter shared with another
* mapper in this file would make that matcher unable to fail.
*/
function serialiseMoodContext(
moodContext: BackupMoodContext,
disasterRecovery: boolean,
) {
return {
...(disasterRecovery
? {
notesEncrypted: moodContext.notesEncrypted
? Buffer.from(moodContext.notesEncrypted).toString("base64")
: null,
}
: { note: readNote(moodContext.notesEncrypted, null) }),
workStatus: moodContext.workStatus,
workMinutes: moodContext.workMinutes,
overtimeMinutes: moodContext.overtimeMinutes,
workLoad: moodContext.workLoad,
workSatisfaction: moodContext.workSatisfaction,
// The two multi-selects ride as the stored JSON string, unparsed. The
// restore writes the column back verbatim, so a file cannot arrive with a
// list the parser and the column disagree about.
contactCircles: moodContext.contactCircles,
contactForm: moodContext.contactForm,
contactExtent: moodContext.contactExtent,
contactQuality: moodContext.contactQuality,
contactSupport: moodContext.contactSupport,
leisureCategories: moodContext.leisureCategories,
leisureMinutes: moodContext.leisureMinutes,
leisureJoy: moodContext.leisureJoy,
leisureRecovery: moodContext.leisureRecovery,
eventType: moodContext.eventType,
eventValence: moodContext.eventValence,
eventAt: moodContext.eventAt?.toISOString() ?? null,
};
}
/**
* Build the canonical full-backup payload for `userId`. Portable exports omit
* tombstones and document ciphertext; disaster-recovery payloads preserve
@@ -267,6 +357,9 @@ export async function buildFullBackupPayload(
// definition — so the present/absent tags, the kind most people pick,
// were absent from the file and could not come back from it.
tagLinks: { select: MOOD_ENTRY_TAG_LINK_SELECT },
// The day context, when the entry has one. Sparse by design — most
// entries carry none, and the join answers null on those.
context: { select: MOOD_CONTEXT_BACKUP_SELECT },
},
}),
// The account's OWN mood tags. The seeded catalogue is reference data every
@@ -520,6 +613,12 @@ export async function buildFullBackupPayload(
deletedAt: moodEntry.deletedAt?.toISOString() ?? null,
factors,
structuredTags,
// The day context, or null. Null rather than an empty object, because
// "no context" and "a context whose every field is empty" are
// different facts and the restore has to keep them apart.
context: moodEntry.context
? serialiseMoodContext(moodEntry.context, disasterRecovery)
: null,
};
}),
customMoodTags: customMoodTags.map((tag) => ({
@@ -0,0 +1,166 @@
/**
* The context × mood comparison: its floors, its correction, and the two
* things it must never do.
*
* The floors are the point of this file. A board that always has something on
* it is a board that means nothing, and the two ways to get one are to show a
* value seen on three days and to filter a wide sweep on a raw p < 0.05. Both
* are tested at their boundary here, because a floor nobody has watched hold
* is a number in a constant.
*/
import { describe, expect, it } from "vitest";
import {
CONTEXT_MIN_ABSENT_DAYS,
CONTEXT_MIN_PRESENT_DAYS,
computeContextMoodComparison,
type ContextDayRow,
} from "@/lib/insights/mood-context-crosstab";
const EMPTY_CONTEXT = {
workStatus: null,
contactCircles: null,
contactForm: null,
contactExtent: null,
leisureCategories: null,
eventType: null,
};
function day(
index: number,
moodA1: number | null,
context: Partial<ContextDayRow> = {},
): ContextDayRow {
return {
day: `2026-03-${String(index + 1).padStart(2, "0")}`,
moodA1,
...EMPTY_CONTEXT,
...context,
};
}
/** `present` days carrying `workStatus: overtime` at `lowMood`, the rest high. */
function overtimeFixture(present: number, absent: number): ContextDayRow[] {
const rows: ContextDayRow[] = [];
for (let i = 0; i < present; i++) {
rows.push(day(i, i % 2 === 0 ? 2 : 3, { workStatus: "overtime" }));
}
for (let i = 0; i < absent; i++) {
rows.push(day(present + i, i % 2 === 0 ? 8 : 7, { workStatus: "regular" }));
}
return rows;
}
describe("context × mood comparison", () => {
it("reports the two counts and the two means it compared", () => {
const rows = computeContextMoodComparison(overtimeFixture(10, 12));
const overtime = rows.find((r) => r.value === "overtime");
expect(overtime, "the overtime comparison did not survive").toBeDefined();
expect(overtime!.field).toBe("workStatus");
expect(overtime!.withDays).toBe(10);
expect(overtime!.withoutDays).toBe(12);
expect(overtime!.withAvg).toBe(2.5);
expect(overtime!.withoutAvg).toBe(7.5);
expect(overtime!.delta).toBe(-5);
});
it("holds the present-day floor at its boundary", () => {
// One below the floor: nothing, however clean the separation looks.
const below = computeContextMoodComparison(
overtimeFixture(CONTEXT_MIN_PRESENT_DAYS - 1, 12),
);
expect(below.some((r) => r.value === "overtime")).toBe(false);
// Exactly at it: the row is allowed to exist.
const at = computeContextMoodComparison(
overtimeFixture(CONTEXT_MIN_PRESENT_DAYS, 12),
);
expect(at.some((r) => r.value === "overtime")).toBe(true);
});
it("holds the absent-day floor at its boundary", () => {
const below = computeContextMoodComparison(
overtimeFixture(10, CONTEXT_MIN_ABSENT_DAYS - 1),
);
expect(below.some((r) => r.value === "overtime")).toBe(false);
const at = computeContextMoodComparison(
overtimeFixture(10, CONTEXT_MIN_ABSENT_DAYS),
);
expect(at.some((r) => r.value === "overtime")).toBe(true);
});
it("folds a day logged twice into one observation", () => {
// Two entries on one day is not two days. Without the fold, somebody who
// logs three times on their worst day drags every comparison that day
// belongs to.
const rows = overtimeFixture(10, 12);
const doubled = [...rows, { ...rows[0], moodA1: 0 }];
const once = computeContextMoodComparison(rows).find(
(r) => r.value === "overtime",
);
const twice = computeContextMoodComparison(doubled).find(
(r) => r.value === "overtime",
);
expect(twice!.withDays).toBe(once!.withDays);
expect(twice!.withAvg).toBe(once!.withAvg);
});
it("skips days with no mood value rather than treating them as zero", () => {
const rows = overtimeFixture(10, 12);
rows.push(day(50, null, { workStatus: "overtime" }));
const overtime = computeContextMoodComparison(rows).find(
(r) => r.value === "overtime",
);
expect(overtime!.withDays).toBe(10);
});
it("reads a multi-select value out of its stored JSON list", () => {
const rows: ContextDayRow[] = [];
for (let i = 0; i < 10; i++) {
rows.push(
day(i, i % 2 === 0 ? 3 : 4, {
contactCircles: JSON.stringify(["partner", "family"]),
}),
);
}
for (let i = 0; i < 12; i++) {
rows.push(
day(10 + i, i % 2 === 0 ? 8 : 7, {
contactCircles: JSON.stringify(["friends"]),
}),
);
}
const circles = computeContextMoodComparison(rows);
expect(circles.find((r) => r.value === "partner")?.withDays).toBe(10);
expect(circles.find((r) => r.value === "friends")?.withDays).toBe(12);
});
it("drops noise a raw p < 0.05 would have surfaced", () => {
// Twenty-odd values tested against a mood series with no structure in it.
// Some pair will look significant at 0.05 sooner or later; the point of
// the family-wide correction is that it does not reach the board.
const values = [
...Array.from({ length: 40 }, (_, i) =>
day(i, (i * 7) % 11, {
contactCircles: JSON.stringify([
["partner", "child", "family", "friends"][i % 4],
]),
leisureCategories: JSON.stringify([
["film", "reading", "gaming", "music"][i % 4],
]),
eventType: ["goodNews", "badNews", "success", "conflict"][i % 4],
}),
),
];
const rows = computeContextMoodComparison(values);
for (const row of rows) {
expect(row.qValue).toBeLessThanOrEqual(0.1);
}
});
it("answers nothing at all on an empty history", () => {
expect(computeContextMoodComparison([])).toEqual([]);
expect(computeContextMoodComparison([day(0, null)])).toEqual([]);
});
});
+122
View File
@@ -16,6 +16,7 @@ import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
import { computeContextMoodComparison } from "@/lib/insights/mood-context-crosstab";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
@@ -200,6 +201,105 @@ async function buildMoodDimensionFeatures(
return blocks.length > 0 ? blocks : undefined;
}
/**
* The day-context comparisons, as the model may state them.
*
* Every row has already cleared the day floors and a family-wide
* Benjamini-Hochberg correction, so nothing here is a raw p < 0.05 finding.
* The counts ride with each row and the block carries one sentence saying
* what the rows are and are not — a model follows a stated constraint far
* more reliably than it infers one from a field name, and the constraint here
* is the difference between an observation and a claim about somebody's life.
*
* Bounded: the same 90-day window the dimensions use, and the comparison's own
* cap on how many rows survive.
*/
async function buildMoodContextBlock(userId: string): Promise<{
context?: {
note: string;
comparisons: Array<{
field: string;
value: string;
withDays: number;
withoutDays: number;
withAvg: number;
withoutAvg: number;
}>;
};
}> {
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const rows = await prisma.moodEntry.findMany({
where: {
userId,
deletedAt: null,
moodLoggedAt: { gte: since },
context: { isNot: null },
},
orderBy: { moodLoggedAt: "desc" },
take: 2000,
select: {
date: true,
moodA1: true,
context: {
select: {
workStatus: true,
contactCircles: true,
contactForm: true,
contactExtent: true,
leisureCategories: true,
eventType: true,
},
},
},
});
if (rows.length === 0) return {};
// Days WITHOUT a context belong on the "without" side of every comparison,
// so the second read takes the whole window rather than only the rows above.
const allDays = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "desc" },
take: 2000,
select: { date: true, moodA1: true },
});
const contextByDay = new Map(rows.map((row) => [row.date, row.context]));
const comparisons = computeContextMoodComparison(
allDays.map((row) => {
const context = contextByDay.get(row.date) ?? null;
return {
day: row.date,
moodA1: row.moodA1,
workStatus: context?.workStatus ?? null,
contactCircles: context?.contactCircles ?? null,
contactForm: context?.contactForm ?? null,
contactExtent: context?.contactExtent ?? null,
leisureCategories: context?.leisureCategories ?? null,
eventType: context?.eventType ?? null,
};
}),
);
if (comparisons.length === 0) return {};
return {
context: {
note:
"Each row compares the average mood value on days that carried a " +
"context entry against the days that did not, over the last 90 days. " +
"These are associations and nothing more: state them with their day " +
"counts, and never say that the context caused the mood.",
comparisons: comparisons.map((row) => ({
field: row.field,
value: row.value,
withDays: row.withDays,
withoutDays: row.withoutDays,
withAvg: row.withAvg,
withoutAvg: row.withoutAvg,
})),
},
};
}
/**
* The dimensions block and the note that says how to read it beside the
* block-level `asOf`. Absent entirely when nobody has answered a dimension,
@@ -346,6 +446,27 @@ export interface AggregatedFeatures {
* Present only alongside `dimensions`.
*/
dimensionsAsOfNote?: string;
/**
* v1.38 — how the average mood value sat on days carrying each day-context
* entry against the days that did not, over the last 90 days.
*
* Present only when at least one comparison cleared the day floors and the
* family-wide correction. The `note` beside the rows states what they are:
* a model that is told in a sentence that these are associations states
* them as associations, and one that is left to infer it from a field name
* does not.
*/
context?: {
note: string;
comparisons: Array<{
field: string;
value: string;
withDays: number;
withoutDays: number;
withAvg: number;
withoutAvg: number;
}>;
};
};
sleep?: {
avg7: number | null;
@@ -1241,6 +1362,7 @@ export async function extractFeatures(
trend30,
totalEntries: moodTotalEntries,
...(await buildMoodDimensionBlock(userId)),
...(await buildMoodContextBlock(userId)),
coverage: {
count: moodTotalEntries,
spanDays,
+57
View File
@@ -47,6 +47,10 @@ export {
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { annotate } from "@/lib/logging/context";
import { createCustomLabelResolver } from "@/lib/mood/custom-tags";
import {
computeContextMoodComparison,
type ContextComparisonRow,
} from "@/lib/insights/mood-context-crosstab";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
computeBetterDays,
@@ -171,6 +175,20 @@ export interface MoodAggregateEntry {
* aggregate tests (flat tags only) keep their narrow fixtures.
*/
structuredTags?: StructuredTagRef[];
/**
* v1.38 — the day's context, when the entry carries one. Only the
* categorical fields: the comparison below tests membership of a value, and
* the numeric ratings are a different question this milestone does not ask.
* Optional so every fixture written before contexts existed keeps its shape.
*/
context?: {
workStatus: string | null;
contactCircles: string | null;
contactForm: string | null;
contactExtent: string | null;
leisureCategories: string | null;
eventType: string | null;
} | null;
/**
* v1.37 — the five level-A values, each 0-10 and each nullable. Optional so
* every fixture written before they existed keeps its narrow shape, and
@@ -453,6 +471,13 @@ export interface MoodAggregates {
*/
stability: MoodStability | null;
tags: TagSummaryRow[];
/**
* v1.38 — how the day's pleasantness sat on days carrying each context
* value against the days that did not. A comparison and never a cause: the
* copy that renders it says so, and every row carries the two counts it was
* computed from so a reader can weigh it themselves.
*/
contextComparison: ContextComparisonRow[];
/** v1.8.5 — structured-tag breakdown from the taxonomy join. */
structuredTags: StructuredTagRow[];
/**
@@ -582,6 +607,23 @@ export function computeMoodAggregates(args: {
userPriorityJson,
});
// v1.38 — every context value against the day's pleasantness, one BH-FDR
// family across the whole sweep. Entries with no context contribute to the
// "without" side of each comparison, which is what makes the counts mean
// what the copy says they mean.
const contextComparison = computeContextMoodComparison(
entries.map((entry) => ({
day: entry.date,
moodA1: entry.a1 ?? null,
workStatus: entry.context?.workStatus ?? null,
contactCircles: entry.context?.contactCircles ?? null,
contactForm: entry.context?.contactForm ?? null,
contactExtent: entry.context?.contactExtent ?? null,
leisureCategories: entry.context?.leisureCategories ?? null,
eventType: entry.context?.eventType ?? null,
})),
);
// Distinct day keys the user logged on — drives the streak takeaway.
const loggedDayKeys = Array.from(new Set(entries.map((entry) => entry.date)));
@@ -624,6 +666,7 @@ export function computeMoodAggregates(args: {
betterDays,
tagMetricCrosstab,
factorCrosstab,
contextComparison,
narratives: computeMoodNarratives({
daily: moodDaily,
weekday,
@@ -677,6 +720,19 @@ export async function fetchMoodAggregates(
// the breakdown can fold structured tags next to the flat ones.
// v1.14.0 — also pull the per-link `rating` + the factor's
// kind/scale/inverse so RATED factors feed the factor crosstab.
// v1.38 — the categorical context fields. The numeric ratings are not
// read here: this surface compares membership of a value against the
// day's pleasantness, and a rating is a different question.
context: {
select: {
workStatus: true,
contactCircles: true,
contactForm: true,
contactExtent: true,
leisureCategories: true,
eventType: true,
},
},
tagLinks: {
select: {
rating: true,
@@ -716,6 +772,7 @@ export async function fetchMoodAggregates(
tags: parseTags(row.tags),
moodLoggedAt: row.moodLoggedAt,
tz: row.tz,
context: row.context,
// BINARY links carry the structured-tag breakdown; RATED links
// (a non-null `rating`) carry the factor score. One join, two axes.
structuredTags: row.tagLinks
+205
View File
@@ -0,0 +1,205 @@
/**
* Day context against the day's mood, with the count on every statement.
*
* The question this answers is "on the days you recorded a conflict, how did
* the mood value sit against the days you did not" — and the answer is a
* comparison, never a cause. Nothing in this file, and nothing rendered from
* it, may say that a context value made a day worse. It did not, and the data
* cannot know whether it did.
*
* The statistics are borrowed rather than rebuilt. The Welch t-test, the
* Benjamini-Hochberg step-up, the per-side day floors and the FDR threshold
* all come from the machinery the tag crosstab already runs. That matters more
* here than it sounds: a context sweep tests a few dozen values at once, and a
* raw p < 0.05 over that many comparisons surfaces one-in-twenty noise as a
* finding. The BH correction is the difference between a board that means
* something and a board that always has something on it.
*
* The floors are constants with names because Phase 3's model will import
* them rather than declare a second set that drifts from these.
*/
import {
CROSSTAB_FDR_Q,
CROSSTAB_MIN_ABSENT_DAYS,
CROSSTAB_MIN_PRESENT_DAYS,
} from "@/lib/insights/mood-crosstab";
import { benjaminiHochberg } from "@/lib/insights/correlation-discovery";
import { welchTTest } from "@/lib/insights/correlations";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
decodeKeyList,
} from "@/lib/mood/context-vocabulary";
/**
* The smallest number of days a context value may be seen on and still be
* shown at all.
*
* Five is the floor the rest of the mood surfaces already use, and it is a
* floor rather than a target: the concept that shaped this feature asks for
* ten, and a reader looking at a row backed by five days should be able to see
* that number and discount it accordingly. Which is why the count is on the
* statement and not in a tooltip.
*/
export const CONTEXT_MIN_PRESENT_DAYS = CROSSTAB_MIN_PRESENT_DAYS;
export const CONTEXT_MIN_ABSENT_DAYS = CROSSTAB_MIN_ABSENT_DAYS;
/** Most rows the board shows, so it stays readable rather than exhaustive. */
export const CONTEXT_MAX_ROWS = 8;
/** One row of a stored context, as the comparison reads it. */
export interface ContextDayRow {
/** The entry's local day. One row per day; a second entry on a day is folded. */
day: string;
/** The day's pleasantness value, 0-10. Days without one are skipped. */
moodA1: number | null;
workStatus: string | null;
contactCircles: string | null;
contactForm: string | null;
contactExtent: string | null;
leisureCategories: string | null;
eventType: string | null;
}
export interface ContextComparisonRow {
/** Which context field this row is about, e.g. `workStatus`. */
field: string;
/** The value inside that field, e.g. `overtime`. */
value: string;
/** Days the value was recorded and the day had a mood value. */
withDays: number;
/** Days it was not, and the day had a mood value. */
withoutDays: number;
/** Mean pleasantness on days carrying the value. */
withAvg: number;
/** Mean pleasantness on the other days. */
withoutAvg: number;
/** `withAvg - withoutAvg`. Signed, and never described as an effect. */
delta: number;
pValue: number;
qValue: number;
}
/** The single-valued fields, and the vocabulary each draws from. */
const SINGLE_FIELDS: Array<{
field: keyof ContextDayRow & string;
keys: readonly string[];
}> = [
{ field: "workStatus", keys: WORK_STATUS_KEYS },
{ field: "contactForm", keys: CONTACT_FORM_KEYS },
{ field: "contactExtent", keys: CONTACT_EXTENT_KEYS },
{ field: "eventType", keys: EVENT_TYPE_KEYS },
];
/** The multi-select fields, stored as JSON arrays in a string column. */
const MULTI_FIELDS: Array<{
field: keyof ContextDayRow & string;
keys: readonly string[];
}> = [
{ field: "contactCircles", keys: CONTACT_CIRCLE_KEYS },
{ field: "leisureCategories", keys: LEISURE_CATEGORY_KEYS },
];
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function mean(values: number[]): number {
return values.reduce((sum, v) => sum + v, 0) / values.length;
}
/**
* Compare every context value against the day's pleasantness.
*
* Pure over already-fetched rows, with the database read left to the caller —
* the shape `mood-aggregates.ts` uses, so the whole comparison is testable
* without a container.
*/
export function computeContextMoodComparison(
rows: readonly ContextDayRow[],
): ContextComparisonRow[] {
// One observation per day. Two entries on one day would otherwise weight
// that day twice, and a day somebody logged three times is not three days.
const byDay = new Map<string, ContextDayRow>();
for (const row of rows) {
if (row.moodA1 === null) continue;
if (!byDay.has(row.day)) byDay.set(row.day, row);
}
if (byDay.size === 0) return [];
const days = [...byDay.values()];
const candidates: ContextComparisonRow[] = [];
const test = (
field: string,
value: string,
carries: (row: ContextDayRow) => boolean,
) => {
const withVals: number[] = [];
const withoutVals: number[] = [];
for (const row of days) {
// `moodA1` is non-null by construction above; the guard keeps the
// narrowing local rather than asserting it.
if (row.moodA1 === null) continue;
if (carries(row)) withVals.push(row.moodA1);
else withoutVals.push(row.moodA1);
}
if (
withVals.length < CONTEXT_MIN_PRESENT_DAYS ||
withoutVals.length < CONTEXT_MIN_ABSENT_DAYS
) {
return;
}
const withAvg = mean(withVals);
const withoutAvg = mean(withoutVals);
const delta = withAvg - withoutAvg;
if (delta === 0) return;
const welch = welchTTest(withVals, withoutVals);
candidates.push({
field,
value,
withDays: withVals.length,
withoutDays: withoutVals.length,
withAvg: round(withAvg, 2),
withoutAvg: round(withoutAvg, 2),
delta: round(delta, 2),
pValue: welch.status === "ok" ? welch.pValue : 1,
qValue: 1,
});
};
for (const { field, keys } of SINGLE_FIELDS) {
for (const value of keys) {
test(field, value, (row) => row[field] === value);
}
}
for (const { field, keys } of MULTI_FIELDS) {
for (const value of keys) {
test(field, value, (row) =>
decodeKeyList(row[field] as string | null).includes(value),
);
}
}
if (candidates.length === 0) return [];
// One family across every field × value pair tested. Correcting per field
// would be six small families instead of one honest one, and the whole
// reason to correct is that the sweep is wide.
const qValues = benjaminiHochberg(candidates.map((c) => c.pValue));
return candidates
.map((c, i) => ({ ...c, qValue: round(qValues[i], 3) }))
.filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q)
.sort(
(a, b) =>
a.qValue - b.qValue ||
Math.abs(b.delta) - Math.abs(a.delta) ||
a.value.localeCompare(b.value),
)
.slice(0, CONTEXT_MAX_ROWS);
}
@@ -0,0 +1,144 @@
/**
* Pair guard binding `context-vocabulary.ts` to the six locale bundles.
*
* The generic i18n guards cannot carry this one. `contextValueLabelKey` builds
* its keys from a template with a literal `mood.context.` prefix, so the
* reverse-coverage matcher records the whole subtree as reachable and stops
* being able to tell a live label from a stale one — the exact degradation
* that file's own doc comment warns about, arriving here by construction
* rather than by accident. So the check that matters is written here, against
* the arrays themselves, in both directions:
*
* * every key the vocabulary names has a non-empty label in all six locales;
* * every label under those groups belongs to a key the vocabulary names.
*
* The second direction is the one that catches a value removed from the
* vocabulary whose translations were left behind, and the first is the one
* that catches a value added without them.
*
* Mutation check: drop `"garden"` from `LEISURE_CATEGORY_KEYS` and the orphan
* assertion goes red naming it in all six bundles; delete
* `mood.context.eventType.conflict` from `messages/pl.json` and the coverage
* assertion goes red naming pl.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
CONTEXT_SECTION_KEYS,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
CONTEXT_RATING_FIELDS,
contextSectionLabelKey,
contextValueLabelKey,
} from "@/lib/mood/context-vocabulary";
const MESSAGES = join(__dirname, "../../../../messages");
const LOCALES = readdirSync(MESSAGES)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(/\.json$/, ""));
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(
readFileSync(join(MESSAGES, `${locale}.json`), "utf8"),
) as Record<string, unknown>;
}
function resolve(root: Record<string, unknown>, key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
root,
);
}
/** The value groups whose members come straight from a vocabulary array. */
const VALUE_GROUPS: Array<{ group: string; keys: readonly string[] }> = [
{ group: "workStatus", keys: WORK_STATUS_KEYS },
{ group: "contactCircles", keys: CONTACT_CIRCLE_KEYS },
{ group: "contactForm", keys: CONTACT_FORM_KEYS },
{ group: "contactExtent", keys: CONTACT_EXTENT_KEYS },
{ group: "leisureCategories", keys: LEISURE_CATEGORY_KEYS },
{ group: "eventType", keys: EVENT_TYPE_KEYS },
];
describe("mood context vocabulary labels", () => {
it("reads six locales and a non-empty vocabulary", () => {
// Both ends have to be there before anything below proves anything. A
// guard that walks an empty list is a green light nobody earned.
expect(LOCALES.length).toBe(6);
expect(VALUE_GROUPS.length).toBeGreaterThan(0);
const total = VALUE_GROUPS.reduce((n, g) => n + g.keys.length, 0);
expect(total).toBeGreaterThan(30);
expect(CONTEXT_RATING_FIELDS.length).toBeGreaterThan(0);
});
for (const locale of LOCALES) {
it(`${locale} carries a label for every vocabulary value`, () => {
const root = bundle(locale);
const missing: string[] = [];
for (const { group, keys } of VALUE_GROUPS) {
for (const key of keys) {
const label = resolve(root, contextValueLabelKey(group, key));
if (typeof label !== "string" || label.trim() === "") {
missing.push(contextValueLabelKey(group, key));
}
}
}
for (const section of CONTEXT_SECTION_KEYS) {
const label = resolve(root, contextSectionLabelKey(section));
if (typeof label !== "string" || label.trim() === "") {
missing.push(contextSectionLabelKey(section));
}
}
for (const field of CONTEXT_RATING_FIELDS) {
for (const anchor of ["low", "high"] as const) {
const key = `mood.context.rating.${field}.${anchor}`;
const label = resolve(root, key);
if (typeof label !== "string" || label.trim() === "") {
missing.push(key);
}
}
}
expect(
missing,
`messages/${locale}.json is missing ${missing.length} context label(s)`,
).toEqual([]);
});
it(`${locale} carries no label the vocabulary no longer names`, () => {
const root = bundle(locale);
const orphans: string[] = [];
for (const { group, keys } of VALUE_GROUPS) {
const node = resolve(root, `mood.context.${group}`);
expect(
node && typeof node === "object",
`mood.context.${group} is absent from ${locale} — this matcher would prove nothing`,
).toBe(true);
for (const present of Object.keys(node as Record<string, unknown>)) {
if (!keys.includes(present)) {
orphans.push(`mood.context.${group}.${present}`);
}
}
}
expect(
orphans,
`messages/${locale}.json carries ${orphans.length} label(s) for values the vocabulary does not name`,
).toEqual([]);
});
}
});
@@ -0,0 +1,62 @@
/**
* The linked block's gating, checked against the one ownership table.
*
* This file exists because the first draft of `linked-context.ts` hand-wrote
* that map and got it wrong in both directions at once: it gated ambient steps
* and active energy on `workouts`, which hides movement behind a toggle the
* user never associated with it, and it declared resting heart rate and HRV
* "core, no owner", which served the `recovery` domain to accounts that had
* switched it off. Neither was catchable by any test that existed, because
* both ends of the claim lived in the same file.
*
* So the claims are pinned to `moduleForMeasurementType()` — the table the MCP
* wire, the correlations reader and the Coach snapshot all resolve through.
* The resolver reads it at runtime, and these assertions say what it must keep
* answering for the block shapes to stay honest.
*
* Mutation check: assign `ACTIVITY_STEPS` an owner in
* `measurement-scope.ts` and the ambient row goes red; move
* `HEART_RATE_VARIABILITY` to a different module from `RESTING_HEART_RATE` and
* the shared-owner row goes red naming both.
*/
import { describe, expect, it } from "vitest";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
describe("linked day context — module ownership", () => {
it("keeps sleep owned by the sleep module", () => {
expect(moduleForMeasurementType("SLEEP_DURATION")).toBe("sleep");
});
it("leaves ambient movement unowned, so the block is never gated", () => {
// `workouts` gates workout SESSIONS. Steps and active energy are on the
// ownership table's reviewed-unscoped list, and gating them here made the
// mood sheet stricter than every other surface in the product.
expect(moduleForMeasurementType("ACTIVITY_STEPS")).toBeNull();
expect(moduleForMeasurementType("ACTIVE_ENERGY_BURNED")).toBeNull();
});
it("keeps both vitals owned by recovery, and by the SAME module", () => {
const rhr = moduleForMeasurementType("RESTING_HEART_RATE");
const hrv = moduleForMeasurementType("HEART_RATE_VARIABILITY");
expect(rhr).toBe("recovery");
expect(hrv).toBe("recovery");
// The block carries one `available` flag for the pair, so the pair has to
// answer to one module. The day they diverge, that single flag becomes a
// lie about whichever of the two it does not describe, and the block has
// to be split before the table is changed.
expect(
hrv,
"the vitals block gates both figures on one module; these two no longer share an owner",
).toBe(rhr);
});
it("carries the HRV fallback in the same module as the primary type", () => {
// A ring-only account resolves HRV to its RMSSD fallback. Left unowned,
// the fallback would serve exactly the recovery data the primary type
// refuses.
expect(moduleForMeasurementType("HRV_RMSSD")).toBe(
moduleForMeasurementType("HEART_RATE_VARIABILITY"),
);
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* The closed vocabularies a mood entry's day context is written from.
*
* Four sections — work, contacts, leisure, a notable event — each with a small
* fixed key list and a handful of numbers. Every key here becomes three
* things at once: a string stored in somebody's row, six locale labels, and a
* feature name any later analysis names in its output. Renaming one after it
* has been stored means a data migration, six locale edits and a re-fit, so
* the list is closed and lives in exactly one file. The Zod enums derive from
* these arrays, which is what makes "a value the vocabulary does not name
* cannot be stored" a structural property rather than a convention.
*
* What is deliberately NOT here:
*
* * Sleep, activity and vitals. They already have a home, and a mood entry
* links to that home instead of asking a second time
* (`src/lib/mood/linked-context.ts`). A stored copy would drift the moment
* somebody corrected a sleep session.
* * Body symptoms. The illness module owns symptom capture and severity, and
* the mood surface links into it rather than growing a parallel form.
* * A productivity rating and a self-determination rating. Both were on the
* table and both were cut: every field a person can leave empty still
* costs a control, six translations and a model feature, and neither of
* those two earned that.
*
* Labels are neutral by rule. "Worked a regular day" is a fact; "worked too
* much" is a judgement, and a judgement about somebody's day belongs to nobody
* but them. Contact circles are groups of people and are never scored: how
* many people somebody saw is not a good or a bad number, which is why the
* quality of the contact is a separate field from the fact of it.
*/
/** Every numeric self-rating in the context shares the level-A 0-10 scale. */
export const CONTEXT_RATING_MIN = 0;
export const CONTEXT_RATING_MAX = 10;
/**
* Minutes bound for the duration fields. A day holds 1440 of them, and a
* bound is what keeps a mistyped digit from landing as a real answer that
* then drags every average it touches.
*/
export const CONTEXT_MINUTES_MIN = 0;
export const CONTEXT_MINUTES_MAX = 1440;
/** How a notable event landed, from clearly bad to clearly good. */
export const CONTEXT_VALENCE_MIN = -5;
export const CONTEXT_VALENCE_MAX = 5;
/**
* The shape of the working day. `off` covers every kind of not-working day —
* a weekend, leave, sick leave — because the reason belongs to the modules
* that own it, not to a mood entry.
*/
export const WORK_STATUS_KEYS = [
"off",
"partial",
"regular",
"overtime",
] as const;
/**
* Who the day's contact was with. Multi-select: a day is rarely one circle.
* `professional` is a therapist, a doctor, a counsellor — a contact that is
* neither private nor a colleague, and one people do have on the days they
* most want to look back at.
*/
export const CONTACT_CIRCLE_KEYS = [
"partner",
"child",
"family",
"friends",
"colleagues",
"acquaintances",
"professional",
"other",
] as const;
/** How the contact happened. */
export const CONTACT_FORM_KEYS = [
"inPerson",
"phone",
"video",
"written",
] as const;
/** Roughly how long it lasted, in the coarse bands people actually recall. */
export const CONTACT_EXTENT_KEYS = ["brief", "medium", "extended"] as const;
/** What the free time was spent on. Multi-select. */
export const LEISURE_CATEGORY_KEYS = [
"film",
"reading",
"gaming",
"music",
"creative",
"outing",
"garden",
"cooking",
"timeWithChild",
"relaxation",
"other",
] as const;
/**
* The kind of thing that happened. One per entry: a day with two notable
* events is a day with two things worth writing in the note, and a list here
* would invite ticking every box rather than naming the one that mattered.
*/
export const EVENT_TYPE_KEYS = [
"goodNews",
"badNews",
"success",
"disappointment",
"conflict",
"appointment",
"familyEvent",
"change",
"unexpected",
] as const;
/**
* Every 0-10 self-rating on the context row, in capture order.
*
* Each one has a low and a high anchor in the locale bundles
* (`mood.context.rating.<field>.{low,high}`), because a bare number on a
* ten-point scale means whatever the person reading it decides it means, and
* an anchor is what makes the value they set last week comparable to the one
* they set today. `workLoad` is inverse-oriented — higher is heavier, not
* better — and it is stored exactly as the person set it against the anchors
* they read. A surface that needs "up is better" flips the sign itself, the
* same rule `MoodEntry.stressA2` follows.
*/
export const CONTEXT_RATING_FIELDS = [
"workLoad",
"workSatisfaction",
"contactQuality",
"contactSupport",
"leisureJoy",
"leisureRecovery",
] as const;
/** The four sections, in capture order. */
export const CONTEXT_SECTION_KEYS = [
"work",
"contacts",
"leisure",
"events",
] as const;
export type ContextSection = (typeof CONTEXT_SECTION_KEYS)[number];
/**
* i18n key for one vocabulary value.
*
* Resolved from data rather than written at a call site, which is why
* `mood.context` sits in the reverse-coverage guard's dynamic allowlist beside
* `mood.dimension` and `mood.tag`.
*/
export function contextValueLabelKey(
field: string,
value: string,
): `mood.context.${string}.${string}` {
return `mood.context.${field}.${value}`;
}
/** i18n key for a section heading. */
export function contextSectionLabelKey(
section: ContextSection,
): `mood.context.section.${ContextSection}` {
return `mood.context.section.${section}`;
}
/**
* The multi-select lists as they are stored: a JSON array in a string column,
* matching `MoodEntry.tags`.
*
* Encoding lives here rather than at the three call sites that need it,
* because a column read by a route, a backup and an analysis is exactly the
* kind of thing that ends up with three parsers and two of them wrong.
*/
export function encodeKeyList(keys: readonly string[] | null): string | null {
if (!keys || keys.length === 0) return null;
return JSON.stringify([...keys]);
}
/** Decode a stored key list. A malformed value reads as empty, never throws. */
export function decodeKeyList(stored: string | null): string[] {
if (!stored) return [];
try {
const parsed: unknown = JSON.parse(stored);
if (!Array.isArray(parsed)) return [];
return parsed.filter((v): v is string => typeof v === "string");
} catch {
return [];
}
}
+218
View File
@@ -0,0 +1,218 @@
/**
* The one place a mood entry's day context crosses between the wire and the
* `mood_contexts` row.
*
* Three call sites need this — the create route, the edit route and the bulk
* route — plus the backup builder and the restore. Left to themselves they
* would each build their own `data` object, and one of them would eventually
* forget a column: exactly how the level-A values shipped twice under two
* spellings before `shapeLevelA` existed. So the mapping lives here, once, in
* both directions.
*
* Everything is built field by field from the parsed body. Nothing is spread
* from a request (`CLAUDE.md`, no mass assignment), and the two multi-select
* lists go through the vocabulary module's encoder rather than a local
* `JSON.stringify`.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
import { decodeKeyList, encodeKeyList } from "@/lib/mood/context-vocabulary";
/** Either the plain client or a transaction client, as the tag-link helper takes. */
type MoodContextDb = PrismaClient | Prisma.TransactionClient;
/**
* The context as a client sends it. Every field optional; a `null` is an
* explicit "take this back", an absent key says nothing at all.
*/
export interface MoodContextInput {
workStatus?: string | null;
workMinutes?: number | null;
overtimeMinutes?: number | null;
workLoad?: number | null;
workSatisfaction?: number | null;
contactCircles?: string[] | null;
contactForm?: string | null;
contactExtent?: string | null;
contactQuality?: number | null;
contactSupport?: number | null;
leisureCategories?: string[] | null;
leisureMinutes?: number | null;
leisureJoy?: number | null;
leisureRecovery?: number | null;
eventType?: string | null;
eventValence?: number | null;
eventAt?: Date | null;
note?: string | null;
}
/** The context as it sits on a row, before the note is decrypted. */
export interface MoodContextRow {
workStatus: string | null;
workMinutes: number | null;
overtimeMinutes: number | null;
workLoad: number | null;
workSatisfaction: number | null;
contactCircles: string | null;
contactForm: string | null;
contactExtent: string | null;
contactQuality: number | null;
contactSupport: number | null;
leisureCategories: string | null;
leisureMinutes: number | null;
leisureJoy: number | null;
leisureRecovery: number | null;
eventType: string | null;
eventValence: number | null;
eventAt: Date | null;
notesEncrypted: Uint8Array | null;
}
/** The context as a client reads it back. Lists decoded, note decrypted. */
export interface MoodContextWire {
workStatus: string | null;
workMinutes: number | null;
overtimeMinutes: number | null;
workLoad: number | null;
workSatisfaction: number | null;
contactCircles: string[];
contactForm: string | null;
contactExtent: string | null;
contactQuality: number | null;
contactSupport: number | null;
leisureCategories: string[];
leisureMinutes: number | null;
leisureJoy: number | null;
leisureRecovery: number | null;
eventType: string | null;
eventValence: number | null;
eventAt: string | null;
note: string | null;
}
/**
* Does this sub-object actually say anything?
*
* An entry whose context sections were all left closed must store **no row**.
* A row of nothing but NULLs would record "asked and answered nothing" where
* the truth is "never asked", and every count over the table would then be a
* count of empty forms. An empty list counts as nothing, because a client that
* renders a multi-select and sends back `[]` has said the same thing as one
* that sent nothing at all.
*/
export function isEmptyContextInput(input: MoodContextInput): boolean {
const values: Array<unknown> = [
input.workStatus,
input.workMinutes,
input.overtimeMinutes,
input.workLoad,
input.workSatisfaction,
input.contactForm,
input.contactExtent,
input.contactQuality,
input.contactSupport,
input.leisureMinutes,
input.leisureJoy,
input.leisureRecovery,
input.eventType,
input.eventValence,
input.eventAt,
];
if (values.some((v) => v !== undefined && v !== null)) return false;
if (input.contactCircles && input.contactCircles.length > 0) return false;
if (input.leisureCategories && input.leisureCategories.length > 0)
return false;
if (input.note !== undefined && input.note !== null && input.note !== "") {
return false;
}
return true;
}
/**
* The write payload for a context row, whole.
*
* Whole rather than per-field, and that is the contract: a context write
* REPLACES the stored context. The capture surface always sends the sections
* it is showing, so a field the payload omits is a field the person cleared,
* and a preserve-when-absent rule here would make an emptied section
* impossible to save. The entry's own columns work the other way round
* (`levelAForWrite`) because those writers are upserts a phone re-posts
* blind; this object is only ever built from a payload that carried a
* `context` key at all.
*/
export function contextRowData(input: MoodContextInput) {
return {
workStatus: input.workStatus ?? null,
workMinutes: input.workMinutes ?? null,
overtimeMinutes: input.overtimeMinutes ?? null,
workLoad: input.workLoad ?? null,
workSatisfaction: input.workSatisfaction ?? null,
contactCircles: encodeKeyList(input.contactCircles ?? null),
contactForm: input.contactForm ?? null,
contactExtent: input.contactExtent ?? null,
contactQuality: input.contactQuality ?? null,
contactSupport: input.contactSupport ?? null,
leisureCategories: encodeKeyList(input.leisureCategories ?? null),
leisureMinutes: input.leisureMinutes ?? null,
leisureJoy: input.leisureJoy ?? null,
leisureRecovery: input.leisureRecovery ?? null,
eventType: input.eventType ?? null,
eventValence: input.eventValence ?? null,
eventAt: input.eventAt ?? null,
notesEncrypted: encryptNote(input.note ?? null),
};
}
/** A stored context row as the client reads it. */
export function contextForWire(row: MoodContextRow): MoodContextWire {
return {
workStatus: row.workStatus,
workMinutes: row.workMinutes,
overtimeMinutes: row.overtimeMinutes,
workLoad: row.workLoad,
workSatisfaction: row.workSatisfaction,
contactCircles: decodeKeyList(row.contactCircles),
contactForm: row.contactForm,
contactExtent: row.contactExtent,
contactQuality: row.contactQuality,
contactSupport: row.contactSupport,
leisureCategories: decodeKeyList(row.leisureCategories),
leisureMinutes: row.leisureMinutes,
leisureJoy: row.leisureJoy,
leisureRecovery: row.leisureRecovery,
eventType: row.eventType,
eventValence: row.eventValence,
eventAt: row.eventAt ? row.eventAt.toISOString() : null,
// Fail-closed like every other note read: a present ciphertext under an
// unknown key id throws rather than answering an empty note.
note: readNote(row.notesEncrypted, null),
};
}
/**
* Write, replace or remove the context of one entry, inside the caller's
* transaction.
*
* `undefined` means the request said nothing about the context and the stored
* one is left alone. A context that says nothing removes the row, which is the
* same rule the create path applies: no row rather than a row of NULLs.
*/
export async function persistMoodContext(
tx: MoodContextDb,
moodEntryId: string,
userId: string,
input: MoodContextInput | null | undefined,
): Promise<"written" | "removed" | "untouched"> {
if (input === undefined) return "untouched";
if (input === null || isEmptyContextInput(input)) {
await tx.moodContext.deleteMany({ where: { moodEntryId } });
return "removed";
}
const data = contextRowData(input);
await tx.moodContext.upsert({
where: { moodEntryId },
create: { moodEntryId, userId, ...data },
update: data,
});
return "written";
}
+424
View File
@@ -0,0 +1,424 @@
/**
* What a mood entry's day looked like in the modules that already own it.
*
* The rule this file exists to serve is the one the whole context feature
* hangs off: one fact, one home. A mood entry shows what the other modules
* already know and never asks for it a second time, and **nothing here is
* copied onto the mood row** — every figure is resolved at read time from the
* rows that own it, so correcting a sleep session corrects it here too, in the
* same moment, with nothing to re-sync.
*
* Three consequences that are easy to get wrong and are therefore stated:
*
* * **Absence is `{ present: false }`, never `0`.** A night nobody recorded
* and a night of no sleep are different facts, and a surface that renders
* the first as the second is lying about somebody's health record.
* * **A module that is switched off blanks its block.** It does not answer
* zero and it does not answer a filtered-down version of itself — the
* block is simply not there, the same way `snapshot.ts` blanks rather than
* filters. Turning a module off is a statement about what the person wants
* to see, not a filter over what they wanted to see.
* * **Which module owns which metric is asked, never assumed.** The first
* draft of this file hand-wrote the ownership: it called steps and active
* energy `workouts`, and it called resting heart rate and HRV core with no
* owner at all. Both were wrong in the two directions that matter — the
* first hid ambient movement behind a toggle the user never associated
* with it, the second served the `recovery` domain to an account that had
* switched it off, which made the release note's own promise false. The
* answer comes from `moduleForMeasurementType()` now, which is the one
* table the MCP wire, the correlations reader and the Coach snapshot all
* resolve through. A second copy of an ownership map is a copy that drifts.
*
* Cross-source de-dup runs on every measurement figure, through the same
* `pickCanonicalSourceRows` ladder the crosstab and the doctor report use.
* Without it, an account syncing a phone and a watch reads its steps doubled
* on the mood sheet — the cumulative channels sum, and two sources reporting
* the same day sum twice.
*
* The reads are bounded: one local day per entry, resolved through the day-key
* helper that already carries the legacy-Berlin fallback for `tz IS NULL`
* rows. There is no unbounded `findMany` here, and adding one would turn a
* detail view into a table scan on an account with years of history.
*/
import type {
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { isModuleEnabled } from "@/lib/modules/gate";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import type { ModuleKey } from "@/lib/modules/registry";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
/** A figure that either exists or honestly does not. */
export type LinkedFigure =
{ present: false } | { present: true; value: number; unit: string };
/** A block that can also be absent because its module is switched off. */
export type LinkedBlock<T> =
{ available: false; reason: "module-disabled" } | ({ available: true } & T);
export interface LinkedSleep {
/** Time asleep for the night the entry's local day woke up on. */
asleep: LinkedFigure;
/** Time in bed, when any writer recorded a bed window. */
inBed: LinkedFigure;
}
/**
* Ambient movement. Never gated: `workouts` gates workout SESSIONS, and steps
* and active energy are on the ownership table's reviewed-unscoped list for
* exactly that reason. The block carries an `available` flag anyway so every
* block in the payload has one shape.
*/
export interface LinkedActivity {
steps: LinkedFigure;
activeEnergy: LinkedFigure;
}
export interface LinkedVitals {
restingHeartRate: LinkedFigure;
heartRateVariability: LinkedFigure;
}
export interface LinkedBody {
/** Whether an illness day-log exists for this day at all. */
logged: boolean;
/** How much the day was limited, 0-3, as the illness module records it. */
functionalImpact: LinkedFigure;
/** Symptoms linked to that day-log, by their catalogue key. */
symptoms: string[];
/** The episode to open in the illness module, when there is one. */
episodeId: string | null;
}
export interface LinkedDayContext {
/** The entry's local day, resolved through the entry's own `tz`. */
day: string;
sleep: LinkedBlock<LinkedSleep>;
activity: LinkedBlock<LinkedActivity>;
/**
* Resting heart rate and HRV. Gated on `recovery`, which is the module the
* ownership table has assigned both types since v1.30.22 — including the
* RMSSD fallback, pinned there so a ring-only account cannot reach through
* the fallback for what the primary type refuses.
*/
vitals: LinkedBlock<LinkedVitals>;
body: LinkedBlock<LinkedBody>;
}
const MODULE_OFF = { available: false, reason: "module-disabled" } as const;
const ABSENT: LinkedFigure = { present: false };
function figure(value: number | null | undefined, unit: string): LinkedFigure {
// A stored zero is a real reading and stays one. Only a missing row is
// absent, which is why this tests for null rather than for falsiness.
return value === null || value === undefined
? ABSENT
: { present: true, value, unit };
}
/** A measurement row as this resolver reads it. */
interface LinkedRow {
type: MeasurementType;
value: number;
measuredAt: Date;
source: MeasurementSource;
deviceType: string | null;
}
/**
* One day's rows for one metric, collapsed to a single source.
*
* The picker walks the user's source ladder and then the device-type ladder,
* so a day reported by both a phone and a watch keeps one stream. Everything
* downstream — the sum, the latest — then describes one device rather than an
* accidental union of two. A metric with no ladder key returns its rows
* untouched, which is the picker's own pass-through and is correct for a type
* no two sources compete over.
*/
function canonicalRowsOfDay(
rows: readonly LinkedRow[],
type: MeasurementType,
day: string,
tz: string,
priorityJson: unknown,
): LinkedRow[] {
const matching = rows.filter((r) => r.type === type);
if (matching.length === 0) return [];
const metricKey = metricKeyForType(type);
if (metricKey === null) return matching;
// The rows are already narrowed to one local day, so the picker's day key
// is constant here; it still goes through the real helper rather than a
// constant, because the ladder resolution is what is wanted and the bucket
// shape is the picker's business.
const { canonicalRows } = pickCanonicalSourceRows(
matching,
metricKey,
priorityJson,
(d) => moodDateKey(d, tz),
);
return canonicalRows.length > 0 ? canonicalRows : matching;
}
/**
* A cumulative metric's day total, over one source.
*
* No rows means absent, not zero: a day nobody's phone reported and a day
* spent motionless are different facts.
*/
function sumOfDay(
rows: readonly LinkedRow[],
type: MeasurementType,
unit: string,
day: string,
tz: string,
priorityJson: unknown,
): LinkedFigure {
const matching = canonicalRowsOfDay(rows, type, day, tz, priorityJson);
if (matching.length === 0) return ABSENT;
return {
present: true,
value: matching.reduce((sum, r) => sum + r.value, 0),
unit,
};
}
/** Latest reading of the day for a point-in-time metric, over one source. */
function latestOfDay(
rows: readonly LinkedRow[],
type: MeasurementType,
unit: string,
day: string,
tz: string,
priorityJson: unknown,
): LinkedFigure {
const matching = canonicalRowsOfDay(rows, type, day, tz, priorityJson).sort(
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
);
if (matching.length === 0) return ABSENT;
return { present: true, value: matching[matching.length - 1].value, unit };
}
/**
* A UTC window guaranteed to contain one local day, whatever its zone.
*
* Generous on both sides on purpose: the exact boundaries are decided by
* `moodDateKey` afterwards, and this only has to be sure it did not clip the
* day off at either end. Doing the arithmetic here instead would mean
* re-deriving a zone offset that the day-key helper already knows, and that
* second derivation is where the DST hour goes missing.
*/
function localDayWindow(day: string): { from: Date; to: Date } {
// Walk outwards from the naive UTC midnight and keep whatever the day-key
// helper agrees belongs to this day. Cheaper and far safer than arithmetic
// on `new Date(y, m, d)`, which slips by an hour twice a year and has
// already been solved once in this codebase.
const naive = new Date(`${day}T00:00:00.000Z`);
const from = new Date(naive.getTime() - 26 * 60 * 60 * 1000);
const to = new Date(naive.getTime() + 50 * 60 * 60 * 1000);
return { from, to };
}
/** Keep only the rows whose own local day is the one asked for. */
function onDay<T extends { measuredAt: Date }>(
rows: readonly T[],
day: string,
tz: string,
): T[] {
return rows.filter((r) => moodDateKey(r.measuredAt, tz) === day);
}
/** The measurement types the linked block reads. One query covers all five. */
const LINKED_MEASUREMENT_TYPES = [
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"ACTIVE_ENERGY_BURNED",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
] as const satisfies readonly MeasurementType[];
/**
* The modules that own anything this resolver reads, asked of the one
* ownership table rather than restated here.
*
* `illness` is added by hand because it owns a table rather than a measurement
* type, so the measurement-keyed table has nothing to say about it. Everything
* else is derived, which is what stops this file from growing a second opinion
* about who owns resting heart rate.
*/
const LINKED_MODULE_KEYS: readonly ModuleKey[] = Array.from(
new Set<ModuleKey>([
...LINKED_MEASUREMENT_TYPES.map(moduleForMeasurementType).filter(
(m): m is ModuleKey => m !== null,
),
"illness",
]),
);
/** Whether the module owning `type` is on, for a resolved gate map. */
function typeAvailable(
type: MeasurementType,
enabled: ReadonlyMap<ModuleKey, boolean>,
): boolean {
const owner = moduleForMeasurementType(type);
// `null` means the ownership table has reviewed this type and assigned it no
// module — ambient movement is the case here. Ungated is the answer, and it
// is the same answer the Coach snapshot and the MCP wire give.
return owner === null ? true : (enabled.get(owner) ?? true);
}
/**
* Resolve the linked figures for one entry's local day.
*
* Takes the entry's own `date` and `tz` rather than re-deriving them, because
* the row already decided which day it belongs to and a second derivation here
* could disagree with it — which is precisely the class of bug the per-row
* `tz` column was added to end.
*/
export async function resolveLinkedDayContext(
userId: string,
entry: { date: string; tz: string | null },
): Promise<LinkedDayContext> {
const tz = entry.tz ?? DEFAULT_TIMEZONE;
const day = entry.date;
const { from, to } = localDayWindow(day);
// Every gate this resolver needs, resolved once against the modules the
// ownership table names. Nothing here decides who owns what.
const gateStates = await Promise.all(
LINKED_MODULE_KEYS.map(
async (key) => [key, await isModuleEnabled(userId, key)] as const,
),
);
const enabled = new Map<ModuleKey, boolean>(gateStates);
const priority = await loadUserSourcePriority(userId);
const measurements: LinkedRow[] = await prisma.measurement.findMany({
where: {
userId,
deletedAt: null,
type: { in: [...LINKED_MEASUREMENT_TYPES] },
measuredAt: { gte: from, lte: to },
},
select: {
type: true,
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
// The canonical-source picker's determinism precondition: it keeps
// insertion order inside a bucket, so a stable read order is what makes
// its device-type tie-break reproducible rather than whatever the planner
// returned this time.
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
});
const dayRows = onDay(measurements, day, tz);
let sleep: LinkedBlock<LinkedSleep> = MODULE_OFF;
if (typeAvailable("SLEEP_DURATION", enabled)) {
// The stage rows of the whole window, not just the day's, and then the
// night whose WAKE day is this one. A night starts the evening before, so
// filtering the raw rows by day first would cut it in half — the
// reconstruction is the thing that knows where a night begins. It runs its
// own per-night source de-dup, which is why these rows do not go through
// the day picker first.
const stageRows = measurements.filter((r) => r.type === "SLEEP_DURATION");
if (stageRows.length === 0) {
sleep = { available: true, asleep: ABSENT, inBed: ABSENT };
} else {
const night = reconstructSleepNights(
stageRows as unknown as SleepStageRow[],
tz,
priority,
).find((n) => n.night === day);
sleep = {
available: true,
asleep: figure(night?.asleepMinutes ?? null, "min"),
inBed: figure(night?.inBedMinutes ?? null, "min"),
};
}
}
// Ambient movement, ungated by the ownership table's own verdict. The
// `typeAvailable` call is not decoration: it is what makes a future change
// to that verdict reach this surface without an edit here.
const activity: LinkedBlock<LinkedActivity> = typeAvailable(
"ACTIVITY_STEPS",
enabled,
)
? {
available: true,
steps: sumOfDay(dayRows, "ACTIVITY_STEPS", "steps", day, tz, priority),
activeEnergy: sumOfDay(
dayRows,
"ACTIVE_ENERGY_BURNED",
"kcal",
day,
tz,
priority,
),
}
: MODULE_OFF;
const vitals: LinkedBlock<LinkedVitals> = typeAvailable(
"RESTING_HEART_RATE",
enabled,
)
? {
available: true,
restingHeartRate: latestOfDay(
dayRows,
"RESTING_HEART_RATE",
"bpm",
day,
tz,
priority,
),
heartRateVariability: latestOfDay(
dayRows,
"HEART_RATE_VARIABILITY",
"ms",
day,
tz,
priority,
),
}
: MODULE_OFF;
let body: LinkedBlock<LinkedBody> = MODULE_OFF;
if (enabled.get("illness") ?? true) {
// Read only. The illness module owns symptom capture and its severity
// scale; the mood surface links into it and captures nothing, which is
// what keeps the two from disagreeing about the same day.
const dayLog = await prisma.illnessDayLog.findFirst({
where: { userId, date: day, deletedAt: null },
select: {
episodeId: true,
functionalImpact: true,
symptomLinks: {
select: { severity: true, symptom: { select: { key: true } } },
},
},
});
body = {
available: true,
logged: dayLog !== null,
functionalImpact: figure(dayLog?.functionalImpact ?? null, "level"),
symptoms: dayLog?.symptomLinks.map((l) => l.symptom.key) ?? [],
episodeId: dayLog?.episodeId ?? null,
};
}
return { day, sleep, activity, vitals, body };
}
+111
View File
@@ -24,6 +24,14 @@ import {
} from "@/lib/mood/custom-tags";
import { moodTagLayoutSchema } from "@/lib/mood/tag-layout";
import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/mood";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
} from "@/lib/mood/context-vocabulary";
import {
dataEnvelope,
errorEnvelope,
@@ -188,6 +196,77 @@ const notFoundResponse = {
},
} as const;
/**
* v1.38 — the day's context on a mood entry, in the one shape both the write
* path and the sync feed use.
*
* Every field optional and nullable. The two multi-selects are closed key
* lists; a value outside them is refused rather than stored, which is what
* keeps the analytics and the six locale label sets from drifting apart from
* the data.
*
* Exported so `./sync.ts` publishes the same object rather than declaring a
* second one — two definitions of one payload is how a client ends up decoding
* a field the server stopped sending.
*/
export const moodContextWire = z
.object({
workStatus: z
.enum(WORK_STATUS_KEYS)
.nullish()
.describe("Shape of the working day."),
workMinutes: z.number().int().min(0).max(1440).nullish(),
overtimeMinutes: z.number().int().min(0).max(1440).nullish(),
workLoad: z
.number()
.int()
.min(0)
.max(10)
.nullish()
.describe(
"0-10, INVERSE-oriented: a higher value is a heavier day, not a better one.",
),
workSatisfaction: z.number().int().min(0).max(10).nullish(),
contactCircles: z
.array(z.enum(CONTACT_CIRCLE_KEYS))
.nullish()
.describe(
"Who the day's contact was with. Multi-select. Never scored: the count of people is not a good or a bad number.",
),
contactForm: z.enum(CONTACT_FORM_KEYS).nullish(),
contactExtent: z.enum(CONTACT_EXTENT_KEYS).nullish(),
contactQuality: z.number().int().min(0).max(10).nullish(),
contactSupport: z.number().int().min(0).max(10).nullish(),
leisureCategories: z.array(z.enum(LEISURE_CATEGORY_KEYS)).nullish(),
leisureMinutes: z.number().int().min(0).max(1440).nullish(),
leisureJoy: z.number().int().min(0).max(10).nullish(),
leisureRecovery: z.number().int().min(0).max(10).nullish(),
eventType: z
.enum(EVENT_TYPE_KEYS)
.nullish()
.describe("One notable event. One per entry, not a list."),
eventValence: z
.number()
.int()
.min(-5)
.max(5)
.nullish()
.describe("-5 (clearly bad) to +5 (clearly good)."),
eventAt: z.iso.datetime({ offset: true }).nullish(),
note: z
.string()
.max(500)
.nullish()
.describe(
"One free-text note for the whole context. AES-256-GCM at rest; the ciphertext never rides the wire.",
),
})
.meta({
id: "MoodContext",
description:
"The day around a mood entry: work, contacts, leisure and one notable event. Every field optional. Sleep, activity, vitals and body symptoms are deliberately ABSENT — they are owned by their own modules and are read from there, never captured a second time here.",
});
// ── Bulk mood backfill (iOS SyncMode) — mirrors the route's
// `bulkPayloadSchema` / `bulkEntrySchema` and the batch-envelope response.
const bulkMoodEntry = z
@@ -212,6 +291,38 @@ const bulkMoodEntry = z
"Rated mood factors; an out-of-scale rating marks THIS entry skipped, never the batch.",
),
note: z.string().max(500).optional(),
a1: z
.number()
.int()
.min(0)
.max(10)
.nullish()
.describe(
"Pleasantness, 0-10. Optional: the server DERIVES it from `mood` when the entry does not carry one, so an older build that sends only the five-point label still writes a complete row. A value sent here wins over the derivation.",
),
a2: z
.number()
.int()
.min(0)
.max(10)
.nullish()
.describe(
'Stress, 0-10, INVERSE-oriented: a higher value is more stress. Stored exactly as set; a client that wants "up is better" flips the sign itself.',
),
a3: z.number().int().min(0).max(10).nullish().describe("Energy, 0-10."),
a4: z
.number()
.int()
.min(0)
.max(10)
.nullish()
.describe("Connectedness, 0-10."),
a5: z.number().int().min(0).max(10).nullish().describe("Stability, 0-10."),
context: moodContextWire
.nullish()
.describe(
"The day's context. Absent leaves a stored context untouched on a re-post; an empty one removes it.",
),
moodLoggedAt: z.iso
.datetime({ offset: true })
.describe("ISO instant the entry was logged."),
+24 -1
View File
@@ -8,6 +8,7 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { measurementResource } from "./measurements";
import { moodContextWire } from "./mood";
import { dataEnvelope, stdResponses } from "./shared";
// ── Sync (v1.7.0 offline / server-optional) ─────────────────────────
@@ -97,7 +98,29 @@ const syncMoodUpsert = z
id: z.string(),
date: z.string().describe("YYYY-MM-DD anchored to the row's `tz`."),
mood: z.string(),
score: z.number().int(),
score: z.number().int().describe("The legacy 1-5 axis. Unchanged."),
a1: z
.number()
.int()
.nullable()
.describe(
"Pleasantness, 0-10. Server-derived from `mood` on every write, so this is populated even for rows a client posted without it. Consume the value; do not re-derive it.",
),
a2: z
.number()
.int()
.nullable()
.describe(
"Stress, 0-10, INVERSE-oriented: a higher value is more stress. NULL when nobody answered it.",
),
a3: z.number().int().nullable().describe("Energy, 0-10, or null."),
a4: z.number().int().nullable().describe("Connectedness, 0-10, or null."),
a5: z.number().int().nullable().describe("Stability, 0-10, or null."),
context: moodContextWire
.nullable()
.describe(
"The day's context, or null when the entry carries none. Null and an object of nulls are different answers.",
),
tags: z.string().nullable().describe("JSON array of tag keys, or null."),
note: z.string().nullable(),
moodLoggedAt: z.iso.datetime({ offset: true }),
+42 -9
View File
@@ -20,22 +20,55 @@ import {
// v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors
// the measurements batch shape but reports the `workouts` count rather
// than the entries count, and the `skipped` field is reserved (the
// Zod schema rejects malformed entries with a 400 before the per-entry
// pass, so today's responses always carry an empty array).
// than the entries count. `skipped` carries the entries the server
// refused: an unusable external id, or a `samples` array that failed
// validation (`reason: "invalid_samples"`).
//
// `enriched` says the entry matched a stored workout that had no
// heart-rate series and the series it carried was attached. The
// workout row itself was not written, so such an entry also counts
// towards `duplicates`. That rule is part of the published contract,
// not just a note here: it rides the field descriptions below, because
// a client summing the counters has to know which bucket an
// enrichment lands in.
const workoutBatchEntryResult = z
.object({
index: z.number().int().nonnegative(),
status: z.enum(["inserted", "duplicate", "skipped"]),
reason: z.string().optional(),
status: z
.enum(["inserted", "duplicate", "enriched", "skipped"])
.describe(
"`inserted`: a new workout row. `duplicate`: the workout was already stored and nothing was written. `enriched`: the workout was already stored and the heart-rate series this entry carried was attached to it; the workout row itself was not written, and the entry also counts towards the top-level `duplicates`. `skipped`: the server refused the entry, see `reason`.",
),
reason: z
.string()
.optional()
.describe(
"Why a `skipped` entry was refused: `unstable_external_id` for an id that does not survive a client restart, `invalid_samples` for a heart-rate series that failed validation.",
),
})
.meta({ id: "WorkoutBatchEntryResult" });
const workoutBatchResponse = z
.object({
processed: z.number().int().nonnegative(),
inserted: z.number().int().nonnegative(),
duplicates: z.number().int().nonnegative(),
processed: z
.number()
.int()
.nonnegative()
.describe(
"Entries in the request. Equals `inserted + duplicates + skipped.length`.",
),
inserted: z
.number()
.int()
.nonnegative()
.describe("Workout rows this request wrote."),
duplicates: z
.number()
.int()
.nonnegative()
.describe(
"Entries that wrote no workout row because the workout was already stored. The top-level counters count workout rows, so an entry reported as `enriched` counts here as well even though its heart-rate series was attached.",
),
skipped: z.array(
z.object({
index: z.number().int().nonnegative(),
@@ -275,7 +308,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "Typed workout batch ingest (v1.4.25 W16b)",
description:
"Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | skipped`) lets the iOS sync cursor checkpoint accurately. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.",
'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.',
requestBody: {
required: true,
content: { "application/json": { schema: createBatchWorkoutSchema } },
+16
View File
@@ -42,6 +42,22 @@ export const moodKeys = {
*/
moodEntriesDay: (date: string) => ["mood-entries", "day", date] as const,
/**
* v1.38 — what the sleep, activity, vitals and illness modules already know
* about one local day, read by the mood capture sheet and the entry detail.
*
* Keyed by the day AND the zone: the same calendar date read under two
* timezones covers two different windows, and one key for both would serve
* whichever answer arrived first.
*
* Its own prefix rather than a segment under `["mood-entries"]`, and
* deliberately: nothing here comes from a mood row, so a mood write has no
* reason to invalidate it, and putting it under that prefix would refetch
* four modules' figures every time somebody tapped a face.
*/
moodLinkedContext: (date: string, tz: string) =>
["mood-linked-context", date, tz] as const,
moodAnalytics: () => ["mood-analytics"] as const,
/**
* v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution,
+39
View File
@@ -289,6 +289,45 @@ const moodEntrySchema = z
// RATED-only meaning, so every file written before this one parses
// unchanged and restores identically: absent reads as none.
structuredTags: z.array(z.string().min(1)).default([]),
// The day context, when the entry had one. Optional and nullable at the
// top: a file written before this existed carries no `context` key at all
// and must parse and restore exactly as it did. Every field inside is
// optional for the same reason a context row's columns are nullable —
// only the sections somebody opened say anything.
//
// The two multi-selects ride as the stored JSON string rather than as
// arrays, so the restore writes the column back verbatim and a file cannot
// arrive with a list the parser and the column disagree about. Nothing is
// re-validated against the vocabulary here: a backup restores what the
// account had, and a key retired between the export and the restore is
// still that person's answer.
context: z
.object({
workStatus: z.string().nullable().optional(),
workMinutes: z.number().int().nullable().optional(),
overtimeMinutes: z.number().int().nullable().optional(),
workLoad: z.number().int().nullable().optional(),
workSatisfaction: z.number().int().nullable().optional(),
contactCircles: z.string().nullable().optional(),
contactForm: z.string().nullable().optional(),
contactExtent: z.string().nullable().optional(),
contactQuality: z.number().int().nullable().optional(),
contactSupport: z.number().int().nullable().optional(),
leisureCategories: z.string().nullable().optional(),
leisureMinutes: z.number().int().nullable().optional(),
leisureJoy: z.number().int().nullable().optional(),
leisureRecovery: z.number().int().nullable().optional(),
eventType: z.string().nullable().optional(),
eventValence: z.number().int().nullable().optional(),
eventAt: isoDateTime.nullable().optional(),
// Same pair as the entry's own note: a portable file carries the
// plain text, a disaster-recovery file the ciphertext.
note: z.string().nullable().optional(),
notesEncrypted: base64BytesSchema.nullable().optional(),
})
.passthrough()
.nullable()
.optional(),
})
.passthrough();
+96
View File
@@ -9,6 +9,20 @@
import { z } from "zod/v4";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { assertStableExternalId } from "@/lib/validations/external-id";
import {
CONTACT_CIRCLE_KEYS,
CONTACT_EXTENT_KEYS,
CONTACT_FORM_KEYS,
CONTEXT_MINUTES_MAX,
CONTEXT_MINUTES_MIN,
CONTEXT_RATING_MAX,
CONTEXT_RATING_MIN,
CONTEXT_VALENCE_MAX,
CONTEXT_VALENCE_MIN,
EVENT_TYPE_KEYS,
LEISURE_CATEGORY_KEYS,
WORK_STATUS_KEYS,
} from "@/lib/mood/context-vocabulary";
// --- CRUD schemas for mood entries ---
@@ -114,6 +128,77 @@ const ratedFactors = z.array(ratedFactor).max(30);
// (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean.
const levelADimension = z.number().int().min(0).max(10);
// v1.38 — the day's context. Every enum below derives from
// `context-vocabulary.ts`, which is what makes "a value the vocabulary does
// not name cannot be stored" structural rather than a convention: adding a key
// to the stored set means adding it to the list the labels and the analytics
// read too, in one edit.
const contextRating = z
.number()
.int()
.min(CONTEXT_RATING_MIN)
.max(CONTEXT_RATING_MAX);
const contextMinutes = z
.number()
.int()
.min(CONTEXT_MINUTES_MIN)
.max(CONTEXT_MINUTES_MAX);
/**
* Optional on create and on patch, and every field inside it optional too.
*
* The bounds live here rather than as database CHECKs, matching
* `MoodEntryTagLink.rating`: an out-of-range value is a client error and
* answers 422 through `returnAllZodIssues`, and a later change to a scale
* cannot strand rows written under the old one.
*
* Sleep, activity, vitals and body symptoms are deliberately absent. They are
* owned by other modules and are read from them
* (`src/lib/mood/linked-context.ts`); accepting them here would mint a second
* home for the same fact.
*/
export const moodContextSchema = z.object({
workStatus: z.enum(WORK_STATUS_KEYS).nullable().optional(),
workMinutes: contextMinutes.nullable().optional(),
overtimeMinutes: contextMinutes.nullable().optional(),
workLoad: contextRating.nullable().optional(),
workSatisfaction: contextRating.nullable().optional(),
contactCircles: z
.array(z.enum(CONTACT_CIRCLE_KEYS))
.max(CONTACT_CIRCLE_KEYS.length)
.nullable()
.optional(),
contactForm: z.enum(CONTACT_FORM_KEYS).nullable().optional(),
contactExtent: z.enum(CONTACT_EXTENT_KEYS).nullable().optional(),
contactQuality: contextRating.nullable().optional(),
contactSupport: contextRating.nullable().optional(),
leisureCategories: z
.array(z.enum(LEISURE_CATEGORY_KEYS))
.max(LEISURE_CATEGORY_KEYS.length)
.nullable()
.optional(),
leisureMinutes: contextMinutes.nullable().optional(),
leisureJoy: contextRating.nullable().optional(),
leisureRecovery: contextRating.nullable().optional(),
eventType: z.enum(EVENT_TYPE_KEYS).nullable().optional(),
eventValence: z
.number()
.int()
.min(CONTEXT_VALENCE_MIN)
.max(CONTEXT_VALENCE_MAX)
.nullable()
.optional(),
// The same plausibility bound the entry's own instant carries: no future
// instants beyond the clock-skew tolerance, nothing before 1900.
eventAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
)
.nullable()
.optional(),
// Capped like the entry note beside it. One note for the whole context.
note: z.string().max(500).nullable().optional(),
});
export const createMoodEntrySchema = z
.object({
mood: moodLevelEnum,
@@ -145,6 +230,12 @@ export const createMoodEntrySchema = z
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
note: z.string().max(500).optional(),
// v1.38 — the day's context. Absent leaves an existing context alone on
// the `update:` arm of an `externalId` upsert, for the same reason A2 to
// A5 are left alone: a phone re-posting an entry from a build that has no
// context surface must not blank one somebody filled in on the web. A
// context that says nothing removes the row.
context: moodContextSchema.nullable().optional(),
// v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no
// future instants beyond a 5-min clock-skew tolerance, no instant before
// 1900. Mirrors the measurement + medication-intake bound.
@@ -186,6 +277,10 @@ export const updateMoodEntrySchema = z.object({
a4: levelADimension.nullable().optional(),
a5: levelADimension.nullable().optional(),
note: z.string().max(500).nullable().optional(),
// v1.38 — full replacement of the context when present, matching the way
// `tagKeys` and `ratedFactors` work on this path: omit to leave it alone,
// send it to replace it, send `null` (or an empty one) to remove the row.
context: moodContextSchema.nullable().optional(),
// v1.17 W1b — same plausibility bound on the edit path.
moodLoggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
@@ -219,3 +314,4 @@ export const listMoodEntriesSchema = z.object({
export type CreateMoodEntryInput = z.infer<typeof createMoodEntrySchema>;
export type UpdateMoodEntryInput = z.infer<typeof updateMoodEntrySchema>;
export type ListMoodEntriesInput = z.infer<typeof listMoodEntriesSchema>;
export type MoodContextRequest = z.infer<typeof moodContextSchema>;
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
INVALID_SAMPLES_REASON,
parseWorkoutBatch,
} from "@/lib/workouts/batch-ingest-parse";
function entry(overrides: Record<string, unknown> = {}) {
return {
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
source: "APPLE_HEALTH",
externalId: "hk-uuid-1",
...overrides,
};
}
describe("parseWorkoutBatch", () => {
it("parses a clean batch and refuses nothing", () => {
const result = parseWorkoutBatch({
workouts: [
entry({ samples: [{ t: "2026-05-14T06:30:00.000Z", hr: 120 }] }),
],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.refusedSampleIndices).toEqual([]);
expect(result.data.workouts[0]?.samples).toHaveLength(1);
});
it("refuses only the entries whose samples array is malformed", () => {
const result = parseWorkoutBatch({
workouts: [
entry({ externalId: "hk-a", samples: [{ t: "nope", hr: 4000 }] }),
entry({ externalId: "hk-b" }),
entry({ externalId: "hk-c", samples: [] }),
],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.refusedSampleIndices).toEqual([0, 2]);
// The survivors keep their full typed shape.
expect(result.data.workouts).toHaveLength(3);
expect(result.data.workouts[1]?.externalId).toBe("hk-b");
expect(result.data.workouts[0]?.samples).toBeUndefined();
});
it("keeps failing the whole batch when any other field is wrong", () => {
const result = parseWorkoutBatch({
workouts: [
entry({ endedAt: "2026-05-14T06:00:00.000Z" }),
entry({ externalId: "hk-b", samples: [{ t: "nope" }] }),
],
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.message).toMatch(/endedAt/i);
});
it("fails the batch when the envelope itself is not a batch", () => {
expect(parseWorkoutBatch({ workouts: [] }).ok).toBe(false);
expect(parseWorkoutBatch(null).ok).toBe(false);
expect(parseWorkoutBatch({ workouts: "nope" }).ok).toBe(false);
});
it("names the per-entry reason code the route reports", () => {
expect(INVALID_SAMPLES_REASON).toBe("invalid_samples");
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Batch-ingest body parsing for `POST /api/workouts/batch`.
*
* The wire contract is `createBatchWorkoutSchema` and stays exactly that:
* this module never widens what the endpoint accepts. What it changes is
* WHERE one specific failure lands.
*
* A malformed field anywhere in the batch fails the whole call with a 400,
* which is right for a client that got the shape wrong. The `samples`
* array is the exception, because of how it arrives: the native client
* re-posts workouts the server already stores purely to hand over their
* heart-rate curve, sweeping years of history in batches of up to a
* hundred. One session whose series came out of HealthKit malformed would
* otherwise stall the entire sweep at a 400 the client cannot repair by
* retrying — every other workout in that batch is fine and has nothing to
* do with the bad one.
*
* So an entry whose `samples` array fails validation is refused on its
* own, exactly the way an entry with an unusable `externalId` already is
* (see `classifyExternalId` in the route), and the rest of the batch
* lands. The refused entry is NOT stripped down and stored without its
* series: the caller asked for the curve to be saved, so a half-write
* would be a silent loss. It is reported back with its index so the
* client knows which session to fix.
*
* Every other validation failure keeps failing the batch.
*/
import {
createBatchWorkoutSchema,
type CreateBatchWorkoutInput,
} from "@/lib/validations/workout";
/** Per-entry `reason` code for a `samples` array that failed validation. */
export const INVALID_SAMPLES_REASON = "invalid_samples";
export type WorkoutBatchParseResult =
| {
ok: true;
data: CreateBatchWorkoutInput;
/** Batch indices whose `samples` array was refused. */
refusedSampleIndices: number[];
}
| { ok: false; message: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Indices of entries whose ONLY problem is the `samples` array. An issue
* path of `["workouts", 3, "samples", ...]` names entry 3; anything else
* means the batch is broken in a way this path does not forgive.
*/
function collectSampleFailureIndices(
issues: ReadonlyArray<{ path: PropertyKey[] }>,
): number[] | null {
const indices = new Set<number>();
for (const issue of issues) {
const [root, index, field] = issue.path;
if (root !== "workouts" || typeof index !== "number" || field !== "samples")
return null;
indices.add(index);
}
return indices.size > 0 ? [...indices].sort((a, b) => a - b) : null;
}
/**
* Parses a batch body, refusing per entry when the only failures are
* `samples` arrays. On that path the offending entries are re-parsed
* without their series so the surviving entries keep their fully typed
* shape; the caller drops the refused indices before any write.
*/
export function parseWorkoutBatch(rawBody: unknown): WorkoutBatchParseResult {
const strict = createBatchWorkoutSchema.safeParse(rawBody);
if (strict.success) {
return { ok: true, data: strict.data, refusedSampleIndices: [] };
}
const refusedSampleIndices = collectSampleFailureIndices(strict.error.issues);
if (
refusedSampleIndices === null ||
!isRecord(rawBody) ||
!Array.isArray(rawBody.workouts)
) {
return {
ok: false,
message: strict.error.issues[0]?.message ?? "Invalid batch",
};
}
const refused = new Set(refusedSampleIndices);
const stripped = {
...rawBody,
workouts: rawBody.workouts.map((entry, index) => {
if (!refused.has(index) || !isRecord(entry)) return entry;
const { samples: _samples, ...rest } = entry;
return rest;
}),
};
const relaxed = createBatchWorkoutSchema.safeParse(stripped);
if (!relaxed.success) {
return {
ok: false,
message: relaxed.error.issues[0]?.message ?? "Invalid batch",
};
}
return { ok: true, data: relaxed.data, refusedSampleIndices };
}