mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
feat(mood): add rated mood factors (ingestion + write contract)
Extend the structured mood taxonomy so a tag can carry a magnitude. A
rated factor is a MoodTag of kind RATED that the user scores per entry;
the score persists on MoodEntryTagLink.rating. Binary tags keep
kind BINARY and a null rating, so every existing row and read path stays
byte-identical.
Migration 0119 is additive: four marker columns on mood_tags (kind,
scale_min, scale_max, inverse), a nullable rating on mood_entry_tag_links,
and a seeded factors category with five MVP factors (work, social, sleep
quality, stress, conflict). Stress and conflict are inverse-scaled
(higher means worse); conflict is a 1..2 Yes/No factor.
Write contract: POST /api/mood-entries and the bulk route accept an
optional ratedFactors: [{ key, rating }] array parallel to the binary
tagKeys. The resolver builds the join rows field-by-field, drops unknown
and non-RATED keys silently, and rejects a rating outside the factor's
own scaleMin..scaleMax (422 on the single-entry path, per-entry skipped
on bulk). Create and list responses surface the persisted factors split
from binary keys so a client hydrates without a refetch. GET
/api/mood/tags now emits kind, scaleMin, scaleMax, and inverse so a
client can render factors versus tags.
This is the ingestion half; analytics over rated factors follow later.
Factor labels ship in all six locales.
This commit is contained in:
1 parent
793602f464
commit
81096b9553
20 files changed
+1100
-43
No files matched your search
@@ -185,6 +185,76 @@ describe("POST /api/mood-entries/bulk (real Postgres)", () => {
|
||||
expect(keys).toEqual(["fast_food", "movies"]);
|
||||
});
|
||||
|
||||
it("persists a rated factor with its score on the link (v1.12.0)", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
ratedFactors: [
|
||||
{ key: "factor_work", rating: 4 },
|
||||
{ key: "factor_conflict", rating: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as { data: { inserted: number } };
|
||||
expect(json.data.inserted).toBe(1);
|
||||
|
||||
const entry = await getPrismaClient().moodEntry.findFirstOrThrow({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
const links = await getPrismaClient().moodEntryTagLink.findMany({
|
||||
where: { moodEntryId: entry.id },
|
||||
select: { rating: true, moodTag: { select: { key: true, kind: true } } },
|
||||
orderBy: { moodTag: { key: "asc" } },
|
||||
});
|
||||
expect(
|
||||
links.map((l) => ({
|
||||
key: l.moodTag.key,
|
||||
kind: l.moodTag.kind,
|
||||
rating: l.rating,
|
||||
})),
|
||||
).toEqual([
|
||||
{ key: "factor_conflict", kind: "RATED", rating: 2 },
|
||||
{ key: "factor_work", kind: "RATED", rating: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks the entry skipped when a rated factor is out of scale (v1.12.0)", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
// factor_conflict scale is 1..2; rating 5 is out of range.
|
||||
ratedFactors: [{ key: "factor_conflict", rating: 5 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { entries: Array<{ status: string }> };
|
||||
};
|
||||
expect(json.data.entries[0].status).toBe("skipped");
|
||||
|
||||
// The mood row upserted, but no rated link landed.
|
||||
const entry = await getPrismaClient().moodEntry.findFirstOrThrow({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
const ratedCount = await getPrismaClient().moodEntryTagLink.count({
|
||||
where: { moodEntryId: entry.id, rating: { not: null } },
|
||||
});
|
||||
expect(ratedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores tagKeys that resolve to no catalog rows", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const res = await POST(
|
||||
|
||||
Reference in new issue
Block a user