feat(mood): add a first-class note column replacing the tag-based workaround

R-E H-5. Up to v0.4.1.1 the iOS app stored mood-entry prose as
`tags: ["note:<text>"]` because the server had no dedicated column.
The workaround contaminated the tags axis with prose the Coach
evidence shelf misread as taxonomic labels.

Schema (migration 0063, additive):
  - mood_entries.note (TEXT, nullable)

Validation + routes:
  - createMoodEntrySchema gains optional note (max 500 chars)
  - updateMoodEntrySchema accepts note + null to clear
  - POST /api/mood-entries persists note alongside tags
  - PUT /api/mood-entries/[id] honours partial note updates
  - POST /api/mood-entries/bulk carries note through the bulk path

Backfill script at scripts/backfill-mood-note-column.ts lifts the
legacy note:<text> tag entries into the new column. Idempotent
dry-run mode is the default; --confirm commits. Helper logic lives
in src/lib/mood/note-backfill.ts for tight unit coverage.

iOS contract: additive. Old iOS payloads (no note field) round-trip
unchanged. New iOS code paths can read or write note without
touching tags.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 12:08:53 +02:00
1 parent 0ccc00b774
commit 11325e31a0
10 files changed
+411 -4

No files matched your search

+3
View File
@@ -88,6 +88,9 @@ export const PUT = apiHandler(
if (data.tags !== undefined) {
updateData.tags = data.tags ? JSON.stringify(data.tags) : null;
}
if (data.note !== undefined) {
updateData.note = data.note;
}
const entry = await prisma.moodEntry.update({
where: { id },
+6 -3
View File
@@ -50,6 +50,7 @@ const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const bulkEntrySchema = z.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
note: z.string().max(500).optional(),
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
source: moodSourceEnum.optional().default("MANUAL"),
/**
@@ -158,16 +159,18 @@ async function postBulk(request: NextRequest): Promise<Response> {
mood: entry.mood,
score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: entry.note ?? null,
source: entry.source,
moodLoggedAt: entry.moodLoggedAt,
},
update: {
// Last-writer-wins on the mood + tags pair. The iOS client
// only re-posts an existing entry when it has new data; the
// server trusts that decision.
// Last-writer-wins on the mood + tags + note triple. The
// iOS client only re-posts an existing entry when it has
// new data; the server trusts that decision.
mood: entry.mood,
score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: entry.note ?? null,
},
});
+2 -1
View File
@@ -90,7 +90,7 @@ async function postMoodEntry(request: NextRequest) {
return apiError(parsed.error.issues[0].message, 422);
}
const { mood, tags, moodLoggedAt, source } = parsed.data;
const { mood, tags, note, moodLoggedAt, source } = parsed.data;
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
// current displayTimezone and store the resolved zone on the row.
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
@@ -108,6 +108,7 @@ async function postMoodEntry(request: NextRequest) {
mood,
score,
tags: tags ? JSON.stringify(tags) : null,
note: note ?? null,
source: source ?? "MANUAL",
moodLoggedAt,
},
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { extractNoteAndSlimTags } from "../note-backfill";
describe("extractNoteAndSlimTags", () => {
it("extracts the first note:<text> entry and slims the tag array", () => {
expect(
extractNoteAndSlimTags(JSON.stringify(["coffee", "note:slept poorly", "workout"])),
).toEqual({
note: "slept poorly",
newTags: JSON.stringify(["coffee", "workout"]),
});
});
it("returns null tags when the slimmed array is empty", () => {
expect(extractNoteAndSlimTags(JSON.stringify(["note:lonely entry"]))).toEqual({
note: "lonely entry",
newTags: null,
});
});
it("returns note=null when no entry starts with note:", () => {
expect(extractNoteAndSlimTags(JSON.stringify(["coffee", "workout"]))).toEqual({
note: null,
newTags: JSON.stringify(["coffee", "workout"]),
});
});
it("extracts only the first note:... entry; further note-prefixed entries stay as tags", () => {
expect(
extractNoteAndSlimTags(JSON.stringify(["note:first", "note:second"])),
).toEqual({
note: "first",
newTags: JSON.stringify(["note:second"]),
});
});
it("returns the input string when it is not valid JSON", () => {
expect(extractNoteAndSlimTags("not-a-json-array")).toEqual({
note: null,
newTags: "not-a-json-array",
});
});
it("returns the input string when the JSON is not an array", () => {
expect(extractNoteAndSlimTags(JSON.stringify({ note: "x" }))).toEqual({
note: null,
newTags: JSON.stringify({ note: "x" }),
});
});
it("preserves the note prefix text exactly (no trim, no decode)", () => {
expect(
extractNoteAndSlimTags(JSON.stringify(["note: with leading space "])),
).toEqual({
note: " with leading space ",
newTags: null,
});
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* v1.4.30 — pure helper for the `MoodEntry.note` backfill (R-E H-5).
*
* Pulls the first `note:<text>` entry out of a JSON-encoded tags array
* and returns the cleaned tags JSON alongside the extracted note. Used
* by `scripts/backfill-mood-note-column.ts` (CLI) — kept in `src/lib/`
* so unit tests can exercise the bucketing without booting `dotenv`
* + the Prisma client.
*/
export function extractNoteAndSlimTags(tagsJson: string): {
note: string | null;
newTags: string | null;
} {
let parsed: unknown;
try {
parsed = JSON.parse(tagsJson);
} catch {
return { note: null, newTags: tagsJson };
}
if (!Array.isArray(parsed)) {
return { note: null, newTags: tagsJson };
}
let extracted: string | null = null;
const remaining: string[] = [];
for (const tag of parsed) {
if (typeof tag !== "string") {
remaining.push(String(tag));
continue;
}
if (extracted === null && tag.startsWith("note:")) {
extracted = tag.slice("note:".length);
} else {
remaining.push(tag);
}
}
return {
note: extracted,
newTags: remaining.length > 0 ? JSON.stringify(remaining) : null,
};
}
+5
View File
@@ -81,6 +81,10 @@ export function getScoreForMood(mood: string): number {
export const createMoodEntrySchema = z.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
// v1.4.30 H-5 — first-class free-text note. Replaces the
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
note: z.string().max(500).optional(),
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
source: moodSourceEnum.optional().default("MANUAL"),
});
@@ -88,6 +92,7 @@ export const createMoodEntrySchema = z.object({
export const updateMoodEntrySchema = z.object({
mood: moodLevelEnum.optional(),
tags: z.array(z.string().max(50)).max(20).nullable().optional(),
note: z.string().max(500).nullable().optional(),
moodLoggedAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))