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:
Marc-André Bombeck committed 2026-06-04 23:09:03 +02:00
1 parent 793602f464
commit 81096b9553
20 files changed
+1100 -43

No files matched your search

+25
View File
@@ -83,6 +83,23 @@ export function getScoreForMood(mood: string): number {
// Bounded so a single create can't fan out an unbounded link set.
const structuredTagKeys = z.array(z.string().max(60)).max(30);
// v1.12.0 — rated mood factors. A factor is a catalog `MoodTag` of
// `kind = 'RATED'`; the user scores it per entry, and the score persists
// on `MoodEntryTagLink.rating`. The wire shape is a parallel array to
// `tagKeys` (binary), keeping the binary contract byte-identical and the
// iOS Codable model simple (`[{ key, rating }]`).
//
// The Zod `rating` bound here is the OUTER envelope (1..5 covers every
// seeded factor's scale). The REAL gate is per-tag: after resolving each
// key to its `MoodTag`, the server rejects a rating outside the tag's own
// `scaleMin..scaleMax` (e.g. 1..2 for `factor_conflict`). See
// `resolveRatedFactors` in `src/lib/mood/tag-links.ts`.
const ratedFactor = z.object({
key: z.string().max(60),
rating: z.number().int().min(1).max(5),
});
const ratedFactors = z.array(ratedFactor).max(30);
export const createMoodEntrySchema = z.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
@@ -91,6 +108,11 @@ export const createMoodEntrySchema = z.object({
// unknown keys are dropped silently (the catalog is the source of
// truth, a stale client can't mint a tag).
tagKeys: structuredTagKeys.optional(),
// v1.12.0 — rated factors scored 1..5 (or the factor's own scale).
// Parallel to the binary `tagKeys`; persisted on
// `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are
// rejected (422) / dropped server-side per the catalog.
ratedFactors: ratedFactors.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.
@@ -105,6 +127,9 @@ export const updateMoodEntrySchema = z.object({
// v1.8.5 — full replacement of the structured-tag set when present.
// `null` clears every link; omit to leave links untouched.
tagKeys: structuredTagKeys.nullable().optional(),
// 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(),
note: z.string().max(500).nullable().optional(),
moodLoggedAt: z.iso
.datetime({ offset: true })