mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge pull request #735 from MBombeck/feat/mood-level-a
Five dimensions behind the mood check-in
This commit is contained in:
47 files changed
+3667
-74
No files matched your search
@@ -774,6 +774,19 @@ const handler = apiHandler(
|
||||
date: entry.date,
|
||||
mood: entry.mood,
|
||||
score: entry.score,
|
||||
// The five level-A values, carried the same way `syncVersion`
|
||||
// is: absent in the file means the file predates the columns,
|
||||
// and an upsert whose update arm stated them would wipe live
|
||||
// answers off every matched row when an older backup is
|
||||
// restored over a going concern. A file that has them writes
|
||||
// them, including an explicit null; a file that does not says
|
||||
// nothing. Never derived — filling one in here would put a
|
||||
// number the person never gave into a restored row.
|
||||
...(entry.a1 === undefined ? {} : { moodA1: entry.a1 }),
|
||||
...(entry.a2 === undefined ? {} : { stressA2: entry.a2 }),
|
||||
...(entry.a3 === undefined ? {} : { energyA3: entry.a3 }),
|
||||
...(entry.a4 === undefined ? {} : { connectionA4: entry.a4 }),
|
||||
...(entry.a5 === undefined ? {} : { stabilityA5: entry.a5 }),
|
||||
tags: entry.tags ?? null,
|
||||
// The note comes back the way every other dual-column note
|
||||
// does: ciphertext verbatim when the file carries it, and a
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, shapeLevelA } 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,11 @@ export const GET = apiHandler(
|
||||
meta: { moodEntryId: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) });
|
||||
return apiSuccess({
|
||||
// v1.37 — level-A values under the keys the write path takes.
|
||||
...shapeLevelA(shapeMoodNote(entry)),
|
||||
tags: parseTags(entry.tags),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -120,7 +125,32 @@ 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.
|
||||
// Only when the label actually moved, though: the edit dialog sends the
|
||||
// whole entry back on every save, and re-deriving on an unchanged label
|
||||
// would overwrite a value the person set by hand every time they
|
||||
// corrected the timestamp. An explicit `a1` below overrides either way.
|
||||
if (data.mood !== existing.mood) {
|
||||
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.
|
||||
//
|
||||
// Pleasantness is the exception, and it is the same exception the create
|
||||
// path makes: an entry always carries a five-point label, so it always
|
||||
// implies a pleasantness value, and a null falls back to the derivation
|
||||
// rather than emptying the column. One rule on both routes — the capture
|
||||
// surfaces therefore offer no clear control for it.
|
||||
if (data.a1 !== undefined) {
|
||||
updateData.moodA1 = data.a1 ?? deriveA1(data.mood ?? existing.mood);
|
||||
}
|
||||
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
|
||||
@@ -290,7 +320,8 @@ export const PUT = apiHandler(
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
...entry,
|
||||
// v1.37 — level-A values under the keys the write path takes.
|
||||
...shapeLevelA(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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
@@ -125,7 +126,11 @@ 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. The column spelling is stripped, not
|
||||
// joined: one number under two names is a choice nobody made.
|
||||
...shapeLevelA(shapeMoodNote(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,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
...levelA,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
@@ -272,11 +289,19 @@ async function postMoodEntry(request: NextRequest) {
|
||||
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),
|
||||
@@ -290,6 +315,9 @@ async function postMoodEntry(request: NextRequest) {
|
||||
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),
|
||||
@@ -374,7 +402,8 @@ async function postMoodEntry(request: NextRequest) {
|
||||
return apiSuccess(
|
||||
{
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
...shapeMoodNote(entry),
|
||||
// v1.37 — level-A values under their wire keys (see the list read).
|
||||
...shapeLevelA(shapeMoodNote(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
|
||||
|
||||
Reference in new issue
Block a user