feat(mood): derive pleasantness on every write path

- one helper resolves the level-A values for a write; the five writers
  call it instead of carrying their own copy of the derivation
- the create schema takes a1 to a5, and a value the user set wins over
  the derived one; the batch request shape is untouched
- both arms of both upserts restate the values, so a re-post cannot
  leave a stale reading beside a mood that has since changed
- the JSON import derives from the row's own label rather than the
  file's score, which is the one place the two can disagree
- correcting an entry's label re-derives pleasantness the way it
  re-derives the score; an explicit null takes an answer back
- a round trip through all five writers against real Postgres asserts
  the rows agree and that nothing invents the other four values
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 00:49:15 +02:00
1 parent ad4f5c04f0
commit a1c78eceef
9 files changed
+555 -1

No files matched your search

+11
View File
@@ -39,6 +39,7 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { isP2002, isP2025 } from "@/lib/prisma-errors";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { deriveA1 } from "@/lib/mood/level-a";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
// Derived from canonical enum so round-trip export → import covers every
@@ -364,6 +365,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
date: e.date,
mood: e.mood,
score: e.score,
// v1.37 — pleasantness derived from the row's own label, not
// from the file's `score`. This is the one writer whose score
// comes out of the file rather than out of the label, so the
// two can disagree; the column that records a self-assessment
// has to agree with the label the user actually picked.
moodA1: deriveA1(e.mood),
tags: e.tags || null,
moodLoggedAt: loggedAt,
},
@@ -372,6 +379,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
date: e.date,
mood: e.mood,
score: e.score,
moodA1: deriveA1(e.mood),
tags: e.tags || null,
source: "IMPORT",
externalId: e.externalId,
@@ -385,6 +393,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
date: e.date,
mood: e.mood,
score: e.score,
// See the upsert arm above: derived from the label, because
// an imported file's `score` is its own field.
moodA1: deriveA1(e.mood),
tags: e.tags || null,
source: "IMPORT",
moodLoggedAt: loggedAt,
+21 -1
View File
@@ -15,6 +15,7 @@ import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { deriveA1, levelAForWire } from "@/lib/mood/level-a";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
@@ -57,7 +58,12 @@ export const GET = apiHandler(
meta: { moodEntryId: id },
});
return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) });
return apiSuccess({
...shapeMoodNote(entry),
// v1.37 — level-A values under the keys the write path takes.
...levelAForWire(entry),
tags: parseTags(entry.tags),
});
},
);
@@ -120,7 +126,19 @@ export const PUT = apiHandler(
if (data.mood !== undefined) {
updateData.mood = data.mood;
updateData.score = getScoreForMood(data.mood);
// v1.37 — pleasantness follows the label the same way `score` does, so
// an entry corrected from "okay" to "bad" cannot keep yesterday's A1.
// An explicit `a1` in the same request overrides it below.
updateData.moodA1 = deriveA1(data.mood);
}
// v1.37 — level-A values are per-field on this path: omitted keeps the
// stored value, an explicit number replaces it, an explicit null clears
// it. Built one at a time from the parsed body, never spread.
if (data.a1 !== undefined) updateData.moodA1 = data.a1;
if (data.a2 !== undefined) updateData.stressA2 = data.a2;
if (data.a3 !== undefined) updateData.energyA3 = data.a3;
if (data.a4 !== undefined) updateData.connectionA4 = data.a4;
if (data.a5 !== undefined) updateData.stabilityA5 = data.a5;
if (data.moodLoggedAt !== undefined) {
// v1.4.25 W7b — re-anchor the row's `date` to the user's current
// displayTimezone. Also refresh the `tz` column so the row's
@@ -291,6 +309,8 @@ export const PUT = apiHandler(
return apiSuccess({
...entry,
// v1.37 — level-A values under the keys the write path takes.
...levelAForWire(entry),
tags: parseTags(entry.tags),
// v1.8.5 — surface the persisted structured-tag keys so a client
// hydrating from the update response renders the tag set without a
+8
View File
@@ -51,6 +51,7 @@ 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 { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
@@ -276,6 +277,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
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);
try {
// v1.12.1 — when the entry carries a source-stable `externalId`,
@@ -347,6 +353,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
tz,
mood: entry.mood,
score,
moodA1,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: null,
noteEncrypted: encryptNote(entry.note ?? null),
@@ -363,6 +370,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
// the same row.
mood: entry.mood,
score,
moodA1,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: null,
noteEncrypted: encryptNote(entry.note ?? null),
+37
View File
@@ -24,6 +24,7 @@ import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { levelAForWire, levelAForWrite } from "@/lib/mood/level-a";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import {
@@ -126,6 +127,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
...shapeMoodNote(e),
// 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
// names to wire names by hand.
...levelAForWire(e),
tags: parseTags(e.tags),
// v1.8.5 — flat list of binary structured-tag keys attached to the
// entry (rated factors are surfaced separately below).
@@ -206,6 +211,17 @@ async function postMoodEntry(request: NextRequest) {
source,
externalId,
} = parsed.data;
// v1.37 — the five level-A values, built field by field from the parsed
// body (never spread) and resolved once for every branch below. A1 falls
// back to the derivation from `mood`; the other four are whatever the user
// set, or nothing.
const levelA = levelAForWrite(mood, {
a1: parsed.data.a1,
a2: parsed.data.a2,
a3: parsed.data.a3,
a4: parsed.data.a4,
a5: parsed.data.a5,
});
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
// current displayTimezone and store the resolved zone on the row.
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
@@ -265,6 +281,11 @@ async function postMoodEntry(request: NextRequest) {
tz,
mood,
score,
moodA1: levelA.moodA1,
stressA2: levelA.stressA2,
energyA3: levelA.energyA3,
connectionA4: levelA.connectionA4,
stabilityA5: levelA.stabilityA5,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
@@ -272,11 +293,20 @@ async function postMoodEntry(request: NextRequest) {
externalId,
moodLoggedAt,
},
// A re-post replaces the row, so the level-A values are restated
// here too. Omitting them would leave a stress reading from an
// earlier post sitting beside a mood the user has since changed
// — the same staleness the `score` restatement already avoids.
update: {
date,
tz,
mood,
score,
moodA1: levelA.moodA1,
stressA2: levelA.stressA2,
energyA3: levelA.energyA3,
connectionA4: levelA.connectionA4,
stabilityA5: levelA.stabilityA5,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
@@ -290,6 +320,11 @@ async function postMoodEntry(request: NextRequest) {
tz,
mood,
score,
moodA1: levelA.moodA1,
stressA2: levelA.stressA2,
energyA3: levelA.energyA3,
connectionA4: levelA.connectionA4,
stabilityA5: levelA.stabilityA5,
tags: tags ? JSON.stringify(tags) : null,
note: null,
noteEncrypted: encryptNote(note ?? null),
@@ -375,6 +410,8 @@ async function postMoodEntry(request: NextRequest) {
{
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
...shapeMoodNote(entry),
// v1.37 — level-A values under their wire keys (see the list read).
...levelAForWire(entry),
tags: parseTags(entry.tags),
// v1.8.5 — surface the persisted structured-tag keys so a client
// hydrating from the create response renders the tag set without a
+5
View File
@@ -41,6 +41,7 @@ import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
import { getScoreForMood } from "@/lib/validations/mood";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { deriveA1 } from "@/lib/mood/level-a";
import {
classifyExternalId,
unstableExternalIdMessage,
@@ -648,6 +649,10 @@ export async function logMcpMood(input: {
tz,
mood,
score,
// v1.37 — pleasantness derived from the same label the score comes
// from. The tool still takes a 1-5 score; that is its input and it is
// unchanged.
moodA1: deriveA1(mood),
note: null,
noteEncrypted: encryptNote(note),
source: MCP_SOURCE,
+5
View File
@@ -17,6 +17,7 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import { prisma as defaultPrisma } from "@/lib/db";
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
import { getScoreForMood } from "@/lib/validations/mood";
import { deriveA1 } from "@/lib/mood/level-a";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
@@ -79,6 +80,10 @@ export async function logTelegramMood(input: {
tz,
mood,
score,
// v1.37 — pleasantness derived from the same label the score comes
// from, so a mood tapped in the chat carries the value a mood typed
// into the web form would. Nothing here can answer the other four.
moodA1: deriveA1(mood),
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
note: null,
noteEncrypted: encryptNote(input.note ?? null),
+100
View File
@@ -0,0 +1,100 @@
/**
* The one place a mood write resolves its five level-A values.
*
* Five writers reach `MoodEntry`, and every one of them sends exactly what it
* sent before this existed: a five-point label and nothing else. The server
* derives pleasantness (A1) from that label so a quick check-in is a full
* entry rather than a degraded one, and so a Telegram row, an imported row and
* a row typed into the web form all carry the same value for the same day.
*
* The other four are never derived. There is nothing in a five-point label to
* derive stress, energy, connectedness or stability from, and a fabricated
* value would read as an answer nobody gave — it would poison the trend and
* any later model trained on these columns. They stay NULL until somebody
* moves the slider.
*/
import { getA1ForMood } from "@/lib/validations/mood";
/** Level-A values as a client may send them, all optional. */
export interface LevelAInput {
a1?: number | null;
a2?: number | null;
a3?: number | null;
a4?: number | null;
a5?: number | null;
}
/** Level-A values as they are written to `MoodEntry`. */
export interface LevelAColumns {
moodA1: number | null;
stressA2: number | null;
energyA3: number | null;
connectionA4: number | null;
stabilityA5: number | null;
}
/**
* The five columns for a write of `mood`, with any explicit client value
* winning over the derivation.
*
* A client that sends only a label gets the derived A1 and four NULLs. A
* client that sends `a1` gets its own number — that is what makes the detail
* sliders a real capture surface rather than a suggestion the server overrides
* on the way to the database.
*
* Every field is stated explicitly, including the NULLs, because the callers
* are upserts: on the `update:` arm an omitted field keeps a value from an
* earlier post, and a stale stress reading sitting beside a mood the user just
* changed is exactly the mismatch these columns exist to avoid. Callers whose
* write semantics are per-field rather than whole-row (the PATCH route) build
* their own object and use `deriveA1` directly.
*/
export function levelAForWrite(
mood: string,
explicit?: LevelAInput,
): LevelAColumns {
return {
moodA1: explicit?.a1 ?? getA1ForMood(mood),
stressA2: explicit?.a2 ?? null,
energyA3: explicit?.a3 ?? null,
connectionA4: explicit?.a4 ?? null,
stabilityA5: explicit?.a5 ?? null,
};
}
/** A1 for a five-point label — the derivation on its own, for writers that carry no level-A input. */
export function deriveA1(mood: string): number {
return getA1ForMood(mood);
}
/** The five columns as a client reads them back. */
export interface LevelAWire {
a1: number | null;
a2: number | null;
a3: number | null;
a4: number | null;
a5: number | null;
}
/**
* Turn a stored row's five columns into the wire keys the client sends.
*
* A read that answered `moodA1` while the write took `a1` would leave every
* form mapping the pair by hand, and one of them would eventually map it
* wrong. The names match in both directions.
*/
export function levelAForWire(row: {
moodA1: number | null;
stressA2: number | null;
energyA3: number | null;
connectionA4: number | null;
stabilityA5: number | null;
}): LevelAWire {
return {
a1: row.moodA1,
a2: row.stressA2,
a3: row.energyA3,
a4: row.connectionA4,
a5: row.stabilityA5,
};
}
+25
View File
@@ -106,6 +106,14 @@ const ratedFactor = z.object({
});
const ratedFactors = z.array(ratedFactor).max(30);
// v1.37 — the five level-A self-state values, each 0-10. Optional on every
// path: a client that sends only a five-point label still writes a complete
// entry, because the server derives A1 from the label. A2 to A5 are only ever
// what the user set, so an omitted one stays absent rather than defaulting to
// the middle of the scale. The bound is the scale itself; `MOOD_DIMENSIONS`
// (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean.
const levelADimension = z.number().int().min(0).max(10);
export const createMoodEntrySchema = z
.object({
mood: moodLevelEnum,
@@ -120,6 +128,14 @@ export const createMoodEntrySchema = z
// `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are
// rejected (422) / dropped server-side per the catalog.
ratedFactors: ratedFactors.optional(),
// v1.37 — the five level-A values the detail sliders capture. Absent
// means the user did not answer; `a1` absent means the server derives it
// from `mood`, which keeps the one-tap check-in a full entry.
a1: levelADimension.optional(),
a2: levelADimension.optional(),
a3: levelADimension.optional(),
a4: levelADimension.optional(),
a5: levelADimension.optional(),
// v1.4.30 H-5 — first-class free-text note. Replaces the
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
@@ -155,6 +171,15 @@ export const updateMoodEntrySchema = z.object({
// v1.12.0 — full replacement of the rated-factor set when present.
// `null` clears every rated link; omit to leave them untouched.
ratedFactors: ratedFactors.nullable().optional(),
// v1.37 — level-A values on the edit path. Omitted leaves the stored value
// alone; an explicit `null` clears it, which is how a user takes back an
// answer they did not mean to give. Editing the five-point label without
// sending `a1` re-derives A1, the same way it re-derives `score`.
a1: levelADimension.nullable().optional(),
a2: levelADimension.nullable().optional(),
a3: levelADimension.nullable().optional(),
a4: levelADimension.nullable().optional(),
a5: levelADimension.nullable().optional(),
note: z.string().max(500).nullable().optional(),
// v1.17 W1b — same plausibility bound on the edit path.
moodLoggedAt: validateEntryInstant(