mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
0ccc00b774
commit
11325e31a0
10 files changed
+411
-4
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -81,6 +81,10 @@ export function getScoreForMood(mood: string): number {
|
||||
export const createMoodEntrySchema = z.object({
|
||||
mood: moodLevelEnum,
|
||||
tags: z.array(z.string().max(50)).max(20).optional(),
|
||||
// v1.4.30 H-5 — first-class free-text note. Replaces the
|
||||
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
|
||||
// Coach evidence shelf renders cleanly without truncating chips.
|
||||
note: z.string().max(500).optional(),
|
||||
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
source: moodSourceEnum.optional().default("MANUAL"),
|
||||
});
|
||||
@@ -88,6 +92,7 @@ export const createMoodEntrySchema = z.object({
|
||||
export const updateMoodEntrySchema = z.object({
|
||||
mood: moodLevelEnum.optional(),
|
||||
tags: z.array(z.string().max(50)).max(20).nullable().optional(),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
moodLoggedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
|
||||
Reference in new issue
Block a user