mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(mood): structured tag taxonomy + note capture and timeline
Add the Category -> Tag taxonomy the mood surface was missing and the free-text note the web form could never write, then surface both on the Insights mood page. Schema (migration 0101): a global MoodTagCategory -> MoodTag catalog plus a MoodEntryTagLink join, seeded with a default five-category set (i18n-keyed labels, Lucide icons). Purely additive — the legacy flat MoodEntry.tags JSON column, the GLP-1 quick-tags, and the Telegram / MoodLog sync are untouched; an entry can carry both axes. Capture: the mood-logging form and the edit sheet gain a Textarea for the note (model + API already accepted it) and an icon-chip picker over the taxonomy catalog (GET /api/mood/tags). Create / update resolve the picked keys to links; unknown keys drop silently against the catalog. Insights: a structured-tag breakdown (icon + per-tag avg-mood lift) and a notes timeline join the existing distribution / weekday / flat-tag sections. The aggregate extension also fixes the flat-tag axis, which read the raw JSON string and so never populated. i18n across all six locales. Tests cover the taxonomy aggregation, the note round-trip, the structured-tag schema, and the notes timeline.
This commit is contained in:
1 parent
b99a015266
commit
cf649f72ce
25 files changed
+1688
-18
No files matched your search
@@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { replaceTagLinks } from "@/lib/mood/tag-links";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -138,6 +139,13 @@ export const PUT = apiHandler(
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// v1.8.5 — full replacement of the structured-tag link set when
|
||||
// `tagKeys` is present in the body. `null` clears every link; an
|
||||
// omitted field leaves the links untouched.
|
||||
if (data.tagKeys !== undefined) {
|
||||
await replaceTagLinks(id, data.tagKeys ?? []);
|
||||
}
|
||||
|
||||
await auditLog("moodEntry.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -22,6 +22,7 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
|
||||
import { createTagLinks } from "@/lib/mood/tag-links";
|
||||
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
@@ -90,6 +91,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
orderBy: { [sortBy]: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
// v1.8.5 — include the structured-tag link keys so the edit form
|
||||
// can pre-populate the taxonomy picker.
|
||||
include: {
|
||||
tagLinks: {
|
||||
select: { moodTag: { select: { key: true } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.moodEntry.count({ where }),
|
||||
]);
|
||||
@@ -99,9 +107,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
|
||||
const entriesWithParsedTags = entries.map((e) => ({
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
|
||||
...e,
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of structured-tag keys attached to the entry.
|
||||
tagKeys: tagLinks.map((link) => link.moodTag.key),
|
||||
}));
|
||||
|
||||
return apiSuccess({
|
||||
@@ -147,7 +157,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { mood, tags, note, moodLoggedAt, source } = parsed.data;
|
||||
const { mood, tags, tagKeys, 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
|
||||
@@ -171,6 +181,14 @@ async function postMoodEntry(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// v1.8.5 — write the structured-tag links. Unknown keys are dropped
|
||||
// inside the helper (the catalog is the source of truth). Best-effort
|
||||
// is wrong here — the links are user-intended content, not a cache —
|
||||
// so a failure surfaces as a 5xx via the outer try/catch.
|
||||
if (tagKeys && tagKeys.length > 0) {
|
||||
await createTagLinks(entry.id, tagKeys);
|
||||
}
|
||||
|
||||
await auditLog("moodEntry.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* v1.8.5 — structured mood-tag taxonomy catalog.
|
||||
*
|
||||
* Returns the active Category -> Tag tree the mood-logging form renders
|
||||
* as a pick-from-catalog capture surface. Global reference data (one
|
||||
* shared catalog for the deployment, seeded by migration 0101), so the
|
||||
* read carries no per-user filter and no encryption. The `labelKey`
|
||||
* fields resolve client-side against the active locale.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
// Auth-gated like every app route — the catalog is not public, but it
|
||||
// is identical for every authenticated user.
|
||||
await requireAuth();
|
||||
|
||||
const categories = await prisma.moodTagCategory.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: {
|
||||
key: true,
|
||||
labelKey: true,
|
||||
icon: true,
|
||||
tags: {
|
||||
where: { isActive: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { key: true, labelKey: true, icon: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.tags.catalog.read" },
|
||||
meta: { category_count: categories.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ categories });
|
||||
});
|
||||
@@ -24,6 +24,14 @@ import {
|
||||
MoodCorrelationCards,
|
||||
type MoodMetricCorrelationData,
|
||||
} from "./mood-correlation-cards";
|
||||
import {
|
||||
MoodStructuredTagBreakdown,
|
||||
type MoodStructuredTagRow,
|
||||
} from "./mood-structured-tag-breakdown";
|
||||
import {
|
||||
MoodNotesTimeline,
|
||||
type MoodNoteEntry,
|
||||
} from "./mood-notes-timeline";
|
||||
|
||||
/**
|
||||
* v1.8.5 — additional Mood Insights sections.
|
||||
@@ -46,6 +54,8 @@ interface MoodInsightsResponse {
|
||||
distribution: MoodDistributionRow[];
|
||||
weekday: MoodWeekdayRow[];
|
||||
tags: MoodTagRow[];
|
||||
structuredTags: MoodStructuredTagRow[];
|
||||
notesTimeline: MoodNoteEntry[];
|
||||
correlations: {
|
||||
sleep: MoodMetricCorrelationData;
|
||||
steps: MoodMetricCorrelationData;
|
||||
@@ -99,6 +109,8 @@ export function MoodInsightsSections() {
|
||||
);
|
||||
|
||||
const hasTags = data.tags.length > 0;
|
||||
const hasStructuredTags = data.structuredTags.length > 0;
|
||||
const hasNotes = data.notesTimeline.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -119,6 +131,12 @@ export function MoodInsightsSections() {
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
{hasStructuredTags && (
|
||||
<SectionCard title={t("insights.mood.structuredTagsTitle")}>
|
||||
<MoodStructuredTagBreakdown tags={data.structuredTags} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{hasTags && (
|
||||
<SectionCard title={t("insights.mood.tagsTitle")}>
|
||||
<MoodTagBreakdown tags={data.tags} />
|
||||
@@ -138,6 +156,12 @@ export function MoodInsightsSections() {
|
||||
pulse={data.correlations.pulse}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasNotes && (
|
||||
<SectionCard title={t("insights.mood.notesTimelineTitle")}>
|
||||
<MoodNotesTimeline notes={data.notesTimeline} />
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { MOOD_LABEL_KEYS } from "@/lib/mood/labels";
|
||||
|
||||
/**
|
||||
* v1.8.5 (C1) — notes timeline.
|
||||
*
|
||||
* Chronological feed of recent mood entries that carry a free-text note,
|
||||
* each with its score, mood label, note text, and tag chips. Renders the
|
||||
* note as a plain React text child (no markdown library — the project
|
||||
* forbids one for XSS reasons).
|
||||
*/
|
||||
|
||||
export interface MoodNoteEntry {
|
||||
date: string;
|
||||
loggedAt: string;
|
||||
score: number;
|
||||
mood: string | null;
|
||||
note: string;
|
||||
tags: string[];
|
||||
structuredTagLabelKeys: string[];
|
||||
}
|
||||
|
||||
export function MoodNotesTimeline({ notes }: { notes: MoodNoteEntry[] }) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<ul className="space-y-3" data-slot="mood-notes-timeline">
|
||||
{notes.map((entry) => {
|
||||
const moodLabel =
|
||||
entry.mood && MOOD_LABEL_KEYS[entry.mood]
|
||||
? t(MOOD_LABEL_KEYS[entry.mood])
|
||||
: entry.mood;
|
||||
return (
|
||||
<li
|
||||
key={`${entry.loggedAt}-${entry.date}`}
|
||||
className="border-border/60 border-l-2 pl-3"
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{entry.score}
|
||||
</span>
|
||||
{moodLabel && <span>({moodLabel})</span>}
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(entry.loggedAt)}</span>
|
||||
</div>
|
||||
<p className="text-foreground/90 mt-0.5 text-sm whitespace-pre-wrap">
|
||||
{entry.note}
|
||||
</p>
|
||||
{(entry.structuredTagLabelKeys.length > 0 ||
|
||||
entry.tags.length > 0) && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{entry.structuredTagLabelKeys.map((labelKey) => (
|
||||
<span
|
||||
key={labelKey}
|
||||
className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
))}
|
||||
{entry.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="border-border/70 text-muted-foreground rounded-full border px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
|
||||
|
||||
/**
|
||||
* v1.8.5 — structured-tag breakdown.
|
||||
*
|
||||
* Ranked icon-bearing rows over the taxonomy tags the user picked,
|
||||
* grouped under their category, with the per-tag average mood ("lift")
|
||||
* as a coloured score. Distinct from the flat free-text breakdown:
|
||||
* structured tags carry icons + categories from the curated catalog.
|
||||
*/
|
||||
|
||||
export interface MoodStructuredTagRow {
|
||||
key: string;
|
||||
categoryKey: string;
|
||||
labelKey: string;
|
||||
icon: string | null;
|
||||
count: number;
|
||||
avgScore: number;
|
||||
}
|
||||
|
||||
function colorForScore(score: number): string {
|
||||
if (score < 2) return "var(--dracula-red)";
|
||||
if (score < 3) return "var(--dracula-orange)";
|
||||
if (score < 3.5) return "var(--dracula-yellow)";
|
||||
return "var(--dracula-green)";
|
||||
}
|
||||
|
||||
export function MoodStructuredTagBreakdown({
|
||||
tags,
|
||||
}: {
|
||||
tags: MoodStructuredTagRow[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const maxCount = tags.reduce((m, row) => Math.max(m, row.count), 0) || 1;
|
||||
|
||||
return (
|
||||
<ul className="space-y-2" data-slot="mood-structured-tag-breakdown">
|
||||
{tags.map((row) => {
|
||||
const Icon = moodTagIcon(row.icon);
|
||||
return (
|
||||
<li key={row.key} className="flex items-center gap-2 text-sm">
|
||||
<Icon
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-foreground w-28 shrink-0 truncate">
|
||||
{t(row.labelKey)}
|
||||
</span>
|
||||
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{
|
||||
width: `${(row.count / maxCount) * 100}%`,
|
||||
backgroundColor: colorForScore(row.avgScore),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground w-6 shrink-0 text-right tabular-nums">
|
||||
{row.count}
|
||||
</span>
|
||||
<span
|
||||
className="w-10 shrink-0 text-right text-xs font-medium tabular-nums"
|
||||
style={{ color: colorForScore(row.avgScore) }}
|
||||
title={t("insights.mood.tagAvgMood")}
|
||||
>
|
||||
{row.avgScore.toFixed(1)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
|
||||
@@ -18,6 +19,7 @@ import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
|
||||
import { MoodTagPicker } from "./mood-tag-picker";
|
||||
|
||||
const MOOD_LEVELS = [
|
||||
{ value: "SUPER_GUT", score: 5, labelKey: "mood.levelSuperGut" },
|
||||
@@ -77,10 +79,22 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
onSelect: (index) => setMood(MOOD_LEVELS[index]!.value),
|
||||
});
|
||||
const [tagsInput, setTagsInput] = useState("");
|
||||
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
|
||||
const [tagKeys, setTagKeys] = useState<string[]>([]);
|
||||
// v1.8.5 (C1) — first-class free-text note. The model + API already
|
||||
// accepted `note`; the web form was the only surface that couldn't
|
||||
// write it.
|
||||
const [note, setNote] = useState("");
|
||||
const [moodLoggedAt, setMoodLoggedAt] = useState(getDefaultMoodLoggedAtValue);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function toggleTagKey(key: string) {
|
||||
setTagKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.27 MB3 — error banner descriptor for the timestamp input.
|
||||
const errorId = useId();
|
||||
const errorDescriptor = error ? errorId : undefined;
|
||||
@@ -93,6 +107,8 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
function resetForm() {
|
||||
setMood("");
|
||||
setTagsInput("");
|
||||
setTagKeys([]);
|
||||
setNote("");
|
||||
setMoodLoggedAt(getDefaultMoodLoggedAtValue());
|
||||
setError(null);
|
||||
}
|
||||
@@ -110,12 +126,16 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const trimmedNote = note.trim();
|
||||
|
||||
const res = await fetch("/api/mood-entries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mood,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : undefined,
|
||||
moodLoggedAt: timestamp,
|
||||
}),
|
||||
});
|
||||
@@ -308,6 +328,36 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 — structured-tag taxonomy picker. Additive next to the
|
||||
free-text input above; an entry can carry both axes. */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("mood.tagPicker")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<MoodTagPicker selected={tagKeys} onToggle={toggleTagKey} />
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mood-note">
|
||||
{t("mood.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="mood-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
id={errorId}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
} from "@/lib/mood/labels";
|
||||
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { MoodTagPicker } from "./mood-tag-picker";
|
||||
|
||||
// Re-export the score map under the legacy local name to keep the
|
||||
// rest of this file unchanged. v1.4.27 B6 / BL-P6-11 — the single
|
||||
@@ -76,6 +78,9 @@ interface MoodEntry {
|
||||
mood: string;
|
||||
score: number;
|
||||
tags: string[];
|
||||
// v1.8.5 — structured-tag keys + free-text note.
|
||||
tagKeys: string[];
|
||||
note: string | null;
|
||||
source: string;
|
||||
moodLoggedAt: string;
|
||||
}
|
||||
@@ -127,8 +132,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
onSelect: (index) => setEditMood(MOOD_LEVELS_LIST[index]!),
|
||||
});
|
||||
const [editTagsInput, setEditTagsInput] = useState("");
|
||||
const [editTagKeys, setEditTagKeys] = useState<string[]>([]);
|
||||
const [editNote, setEditNote] = useState("");
|
||||
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
function toggleEditTagKey(key: string) {
|
||||
setEditTagKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
}
|
||||
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
|
||||
// v1.4.27 R4 RC2 — Sheet-branch sticky-pinned footer slot.
|
||||
const editFormId = useId();
|
||||
@@ -194,17 +207,21 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
id,
|
||||
mood,
|
||||
tags,
|
||||
tagKeys,
|
||||
note,
|
||||
moodLoggedAt,
|
||||
}: {
|
||||
id: string;
|
||||
mood: string;
|
||||
tags: string[] | null;
|
||||
tagKeys: string[];
|
||||
note: string | null;
|
||||
moodLoggedAt: string;
|
||||
}) => {
|
||||
const res = await fetch(`/api/mood-entries/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mood, tags, moodLoggedAt }),
|
||||
body: JSON.stringify({ mood, tags, tagKeys, note, moodLoggedAt }),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
@@ -228,6 +245,8 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
setEditing(entry);
|
||||
setEditMood(entry.mood);
|
||||
setEditTagsInput(entry.tags.join(", "));
|
||||
setEditTagKeys(entry.tagKeys ?? []);
|
||||
setEditNote(entry.note ?? "");
|
||||
setEditMoodLoggedAt(toDateTimeLocalValue(entry.moodLoggedAt));
|
||||
setEditError(null);
|
||||
}
|
||||
@@ -266,11 +285,15 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const trimmedNote = editNote.trim();
|
||||
|
||||
setEditError(null);
|
||||
updateMutation.mutate({
|
||||
id: editing.id,
|
||||
mood: editMood,
|
||||
tags: tags.length > 0 ? tags : null,
|
||||
tagKeys: editTagKeys,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : null,
|
||||
moodLoggedAt: measuredDate.toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -395,6 +418,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{entry.tags.length > 0 ? entry.tags.join(", ") : "-"}
|
||||
{entry.note && (
|
||||
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDateTime(entry.moodLoggedAt)}
|
||||
@@ -467,6 +495,11 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
{entry.tags.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{entry.note && (
|
||||
<p className="text-muted-foreground/80 truncate text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -603,6 +636,32 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 — structured-tag taxonomy picker. */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("mood.tagPicker")} ({t("common.optional")})
|
||||
</Label>
|
||||
<MoodTagPicker
|
||||
selected={editTagKeys}
|
||||
onToggle={toggleEditTagKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-mood-note">
|
||||
{t("mood.note")} ({t("common.optional")})
|
||||
</Label>
|
||||
<Textarea
|
||||
id="edit-mood-note"
|
||||
value={editNote}
|
||||
onChange={(e) => setEditNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Apple,
|
||||
BedDouble,
|
||||
Brain,
|
||||
Briefcase,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
CloudMoon,
|
||||
CloudSun,
|
||||
Dumbbell,
|
||||
Flame,
|
||||
Footprints,
|
||||
Frown,
|
||||
GlassWater,
|
||||
HandHeart,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
HelpCircle,
|
||||
Home,
|
||||
LogOut,
|
||||
Meh,
|
||||
Moon,
|
||||
MoonStar,
|
||||
PartyPopper,
|
||||
Plane,
|
||||
Smile,
|
||||
Tag,
|
||||
Thermometer,
|
||||
ThumbsUp,
|
||||
User,
|
||||
Users,
|
||||
Wine,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* v1.8.5 — icon-name → Lucide component map for the structured mood-tag
|
||||
* taxonomy. The catalog stores Lucide icon names as strings (so the
|
||||
* server stays icon-library-agnostic); this map resolves them on the
|
||||
* client. Mirrors the static-map convention used by the workout / sport
|
||||
* icon lookups. `Tag` is the fallback for an unmapped name.
|
||||
*/
|
||||
const MOOD_TAG_ICONS: Record<string, LucideIcon> = {
|
||||
AlertTriangle,
|
||||
Apple,
|
||||
BedDouble,
|
||||
Brain,
|
||||
Briefcase,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
CloudMoon,
|
||||
CloudSun,
|
||||
Dumbbell,
|
||||
Flame,
|
||||
Footprints,
|
||||
Frown,
|
||||
GlassWater,
|
||||
HandHeart,
|
||||
Heart,
|
||||
HeartPulse,
|
||||
HelpCircle,
|
||||
Home,
|
||||
LogOut,
|
||||
Meh,
|
||||
Moon,
|
||||
MoonStar,
|
||||
PartyPopper,
|
||||
Plane,
|
||||
Smile,
|
||||
Thermometer,
|
||||
ThumbsUp,
|
||||
User,
|
||||
Users,
|
||||
Wine,
|
||||
Zap,
|
||||
};
|
||||
|
||||
export function moodTagIcon(name: string | null | undefined): LucideIcon {
|
||||
if (!name) return Tag;
|
||||
return MOOD_TAG_ICONS[name] ?? Tag;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { moodTagIcon } from "./mood-tag-icons";
|
||||
|
||||
/**
|
||||
* v1.8.5 — structured mood-tag capture surface.
|
||||
*
|
||||
* Loads the global Category -> Tag catalog from `/api/mood/tags` and
|
||||
* renders one labelled group per category with icon chips the user can
|
||||
* toggle. Selection is a controlled set of tag keys lifted to the
|
||||
* parent form, which sends them as `tagKeys` on create / update.
|
||||
*
|
||||
* Additive next to the legacy free-text tag input: a user can use
|
||||
* either or both. Renders nothing if the catalog is empty so a
|
||||
* deployment that cleared the taxonomy degrades to the free-text field.
|
||||
*/
|
||||
|
||||
interface CatalogTag {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
interface CatalogCategory {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
icon: string | null;
|
||||
tags: CatalogTag[];
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
categories: CatalogCategory[];
|
||||
}
|
||||
|
||||
export function MoodTagPicker({
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
selected: string[];
|
||||
onToggle: (tagKey: string) => void;
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.moodTagCatalog(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/mood/tags");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as CatalogResponse;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
// The catalog only changes on a migration / admin edit.
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-24 w-full rounded-lg" />;
|
||||
}
|
||||
|
||||
if (!data || data.categories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedSet = new Set(selected);
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="mood-tag-picker">
|
||||
{data.categories.map((category) => {
|
||||
const CategoryIcon = moodTagIcon(category.icon);
|
||||
return (
|
||||
<div key={category.key} className="space-y-1.5">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
<CategoryIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{t(category.labelKey)}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{category.tags.map((tag) => {
|
||||
const TagIcon = moodTagIcon(tag.icon);
|
||||
const isActive = selectedSet.has(tag.key);
|
||||
const label = t(tag.labelKey);
|
||||
return (
|
||||
<button
|
||||
key={tag.key}
|
||||
type="button"
|
||||
onClick={() => onToggle(tag.key)}
|
||||
aria-pressed={isActive}
|
||||
className={`inline-flex min-h-8 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||
isActive
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<TagIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
computeInTargetPct,
|
||||
computeMoodAggregates,
|
||||
computeMoodMetricCorrelation,
|
||||
computeNotesTimeline,
|
||||
computeStructuredTagSummary,
|
||||
computeTagSummary,
|
||||
computeWeekdayAverages,
|
||||
selectHeatmapWindow,
|
||||
type CrossMetricMeasurement,
|
||||
type MoodAggregateEntry,
|
||||
type StructuredTagRef,
|
||||
} from "../mood-aggregates";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
@@ -78,6 +81,79 @@ describe("computeTagSummary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeStructuredTagSummary", () => {
|
||||
function entryWithStructured(
|
||||
offset: number,
|
||||
score: number,
|
||||
structuredTags: StructuredTagRef[],
|
||||
): MoodAggregateEntry {
|
||||
return { ...entry(offset, score, null), structuredTags };
|
||||
}
|
||||
|
||||
it("ranks structured tags by frequency, keeps singletons, and groups by category", () => {
|
||||
const entries: MoodAggregateEntry[] = [
|
||||
entryWithStructured(1, 5, [
|
||||
{ key: "happy", categoryKey: "feelings", labelKey: "mood.tag.happy", icon: "Smile" },
|
||||
{ key: "worked_out", categoryKey: "health", labelKey: "mood.tag.workedOut", icon: "Dumbbell" },
|
||||
]),
|
||||
entryWithStructured(2, 3, [
|
||||
{ key: "happy", categoryKey: "feelings", labelKey: "mood.tag.happy", icon: "Smile" },
|
||||
]),
|
||||
entryWithStructured(3, 1, [
|
||||
{ key: "stressed", categoryKey: "feelings", labelKey: "mood.tag.stressed", icon: "Brain" },
|
||||
]),
|
||||
];
|
||||
const rows = computeStructuredTagSummary(entries, NOW);
|
||||
const happy = rows.find((r) => r.key === "happy");
|
||||
expect(happy).toEqual({
|
||||
key: "happy",
|
||||
categoryKey: "feelings",
|
||||
labelKey: "mood.tag.happy",
|
||||
icon: "Smile",
|
||||
count: 2,
|
||||
avgScore: 4,
|
||||
});
|
||||
// singletons are kept for the structured breakdown (curated catalog,
|
||||
// not noisy free text).
|
||||
expect(rows.find((r) => r.key === "stressed")?.count).toBe(1);
|
||||
expect(rows.find((r) => r.key === "worked_out")?.categoryKey).toBe("health");
|
||||
// ranked by frequency desc.
|
||||
expect(rows[0].key).toBe("happy");
|
||||
});
|
||||
|
||||
it("excludes entries older than the window and ignores entries without structured tags", () => {
|
||||
const entries: MoodAggregateEntry[] = [
|
||||
entryWithStructured(1, 5, [
|
||||
{ key: "happy", categoryKey: "feelings", labelKey: "mood.tag.happy", icon: "Smile" },
|
||||
]),
|
||||
entryWithStructured(200, 5, [
|
||||
{ key: "sad", categoryKey: "feelings", labelKey: "mood.tag.sad", icon: "Frown" },
|
||||
]),
|
||||
entry(2, 4, ["flat-only"]), // no structuredTags → ignored here
|
||||
];
|
||||
const rows = computeStructuredTagSummary(entries, NOW, 90);
|
||||
expect(rows.map((r) => r.key)).toEqual(["happy"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeNotesTimeline", () => {
|
||||
it("returns noted entries newest-first, skips empty notes, and caps at the limit", () => {
|
||||
const entries: MoodAggregateEntry[] = [
|
||||
{ ...entry(5, 2, ["x"]), note: "oldest", mood: "SCHLECHT" },
|
||||
{ ...entry(3, 4, null), note: " ", mood: "GUT" }, // blank → skipped
|
||||
{ ...entry(2, 5, null), note: "middle", mood: "SUPER_GUT" },
|
||||
{ ...entry(1, 3, null), note: "newest", mood: "OKAY" },
|
||||
];
|
||||
const timeline = computeNotesTimeline(entries, 2);
|
||||
expect(timeline.map((n) => n.note)).toEqual(["newest", "middle"]);
|
||||
expect(timeline[0]).toMatchObject({ score: 3, mood: "OKAY", note: "newest" });
|
||||
});
|
||||
|
||||
it("returns an empty array when no entry carries a note", () => {
|
||||
expect(computeNotesTimeline([entry(1, 4, ["x"])])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeInTargetPct", () => {
|
||||
it("returns the share of the newest 30 daily buckets in the green band", () => {
|
||||
const daily = [
|
||||
|
||||
@@ -28,6 +28,24 @@ import { round, summarizeSeries } from "@/lib/insights/status-shared";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Parse the flat `MoodEntry.tags` JSON string into a string array. The
|
||||
* column stores a JSON-encoded array (or null); the read path must
|
||||
* decode it before `computeTagSummary`'s `Array.isArray` check, otherwise
|
||||
* the flat-tag axis silently collapses to empty (the raw string is never
|
||||
* an array). Defensive against malformed JSON — returns `[]` on a parse
|
||||
* failure rather than throwing.
|
||||
*/
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(tags);
|
||||
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal daily-bucket shape the aggregates read. `DailyBucket` (from
|
||||
* `bucket-series`) carries an extra `n` field; the helpers only ever
|
||||
@@ -36,6 +54,23 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
*/
|
||||
type DailyPoint = Pick<DailyBucket, "dayOffset" | "value">;
|
||||
|
||||
/**
|
||||
* A structured tag attached to a mood entry, resolved from the catalog
|
||||
* (`mood_tags` + its parent `mood_tag_categories`) via the
|
||||
* `mood_entry_tag_links` join. Carried alongside the legacy flat `tags`
|
||||
* string so the breakdown can surface both axes.
|
||||
*/
|
||||
export interface StructuredTagRef {
|
||||
/** Stable tag key (e.g. `happy`). */
|
||||
key: string;
|
||||
/** Parent category key (e.g. `feelings`). */
|
||||
categoryKey: string;
|
||||
/** i18n message key for the tag label. */
|
||||
labelKey: string;
|
||||
/** Lucide icon name, or null. */
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
/** Raw mood row shape the aggregates operate on. */
|
||||
export interface MoodAggregateEntry {
|
||||
/** TZ-anchored day key (YYYY-MM-DD). */
|
||||
@@ -46,6 +81,59 @@ export interface MoodAggregateEntry {
|
||||
tags: unknown;
|
||||
/** Exact log timestamp — carries time-of-day. */
|
||||
moodLoggedAt: Date;
|
||||
/**
|
||||
* v1.8.5 — structured tags from the taxonomy join. Additive next to
|
||||
* the flat `tags`: an entry can carry both. Optional so the legacy
|
||||
* aggregate tests (flat tags only) keep their narrow fixtures.
|
||||
*/
|
||||
structuredTags?: StructuredTagRef[];
|
||||
/** v1.8.5 — free-text note (C1). */
|
||||
note?: string | null;
|
||||
/** Mood enum (for the notes timeline label / colour). */
|
||||
mood?: string;
|
||||
}
|
||||
|
||||
// --- Notes timeline (v1.8.5 C1 — qualitative reflection feed) ---
|
||||
|
||||
export interface NoteTimelineEntry {
|
||||
/** TZ-anchored day key. */
|
||||
date: string;
|
||||
/** ISO timestamp of the log. */
|
||||
loggedAt: string;
|
||||
score: number;
|
||||
mood: string | null;
|
||||
note: string;
|
||||
/** Flat free-text tags on the entry. */
|
||||
tags: string[];
|
||||
/** Structured-tag labels (i18n keys) on the entry. */
|
||||
structuredTagLabelKeys: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent entries that carry a free-text note, newest first. Bounded to
|
||||
* `limit` so the cached aggregate payload stays small. Entries without a
|
||||
* note are skipped — the timeline is the qualitative-reflection feed.
|
||||
*/
|
||||
export function computeNotesTimeline(
|
||||
entries: MoodAggregateEntry[],
|
||||
limit = 20,
|
||||
): NoteTimelineEntry[] {
|
||||
return entries
|
||||
.filter((entry) => typeof entry.note === "string" && entry.note.trim())
|
||||
.slice()
|
||||
.reverse() // entries arrive oldest-first; show newest first
|
||||
.slice(0, limit)
|
||||
.map((entry) => ({
|
||||
date: entry.date,
|
||||
loggedAt: entry.moodLoggedAt.toISOString(),
|
||||
score: entry.score,
|
||||
mood: entry.mood ?? null,
|
||||
note: entry.note!.trim(),
|
||||
tags: Array.isArray(entry.tags) ? (entry.tags as string[]) : [],
|
||||
structuredTagLabelKeys: (entry.structuredTags ?? []).map(
|
||||
(tag) => tag.labelKey,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cross-metric row shape (WEIGHT / BLOOD_PRESSURE_SYS / PULSE / …). */
|
||||
@@ -147,6 +235,71 @@ export function computeTagSummary(
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Structured-tag frequency + per-tag avg score (v1.8.5 taxonomy) ---
|
||||
|
||||
export interface StructuredTagRow {
|
||||
key: string;
|
||||
categoryKey: string;
|
||||
labelKey: string;
|
||||
icon: string | null;
|
||||
count: number;
|
||||
avgScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frequency + per-tag average mood for the structured taxonomy tags,
|
||||
* over the same recency window as the flat-tag summary.
|
||||
*
|
||||
* Unlike `computeTagSummary` (flat free text), singletons are KEPT — a
|
||||
* structured tag comes from a curated catalog the user deliberately
|
||||
* picked, so a single occurrence is signal, not noise. Ranked by
|
||||
* frequency desc, then label key for a stable order. The category key
|
||||
* rides along so the UI can group the bars by category with icons.
|
||||
*/
|
||||
export function computeStructuredTagSummary(
|
||||
entries: MoodAggregateEntry[],
|
||||
now: Date,
|
||||
windowDays = 90,
|
||||
): StructuredTagRow[] {
|
||||
const cutoff = now.getTime() - windowDays * MS_PER_DAY;
|
||||
const byKey = new Map<
|
||||
string,
|
||||
{
|
||||
categoryKey: string;
|
||||
labelKey: string;
|
||||
icon: string | null;
|
||||
count: number;
|
||||
scoreSum: number;
|
||||
}
|
||||
>();
|
||||
for (const entry of entries) {
|
||||
if (entry.moodLoggedAt.getTime() < cutoff) continue;
|
||||
if (!entry.structuredTags) continue;
|
||||
for (const tag of entry.structuredTags) {
|
||||
const current = byKey.get(tag.key) ?? {
|
||||
categoryKey: tag.categoryKey,
|
||||
labelKey: tag.labelKey,
|
||||
icon: tag.icon,
|
||||
count: 0,
|
||||
scoreSum: 0,
|
||||
};
|
||||
current.count += 1;
|
||||
current.scoreSum += entry.score;
|
||||
byKey.set(tag.key, current);
|
||||
}
|
||||
}
|
||||
return Array.from(byKey.entries())
|
||||
.map(([key, stats]) => ({
|
||||
key,
|
||||
categoryKey: stats.categoryKey,
|
||||
labelKey: stats.labelKey,
|
||||
icon: stats.icon,
|
||||
count: stats.count,
|
||||
avgScore: round(stats.scoreSum / stats.count, 2),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count || a.labelKey.localeCompare(b.labelKey));
|
||||
}
|
||||
|
||||
// --- In-target % over the last 30 daily points (lifted from mood-status) ---
|
||||
|
||||
/**
|
||||
@@ -346,6 +499,10 @@ export interface MoodAggregates {
|
||||
distribution: DistributionRow[];
|
||||
weekday: WeekdayRow[];
|
||||
tags: TagSummaryRow[];
|
||||
/** v1.8.5 — structured-tag breakdown from the taxonomy join. */
|
||||
structuredTags: StructuredTagRow[];
|
||||
/** v1.8.5 — recent notes timeline (qualitative-reflection feed). */
|
||||
notesTimeline: NoteTimelineEntry[];
|
||||
correlations: {
|
||||
sleep: MoodMetricCorrelation;
|
||||
steps: MoodMetricCorrelation;
|
||||
@@ -424,6 +581,8 @@ export function computeMoodAggregates(args: {
|
||||
distribution: computeDistribution(moodDaily),
|
||||
weekday: computeWeekdayAverages(moodDaily, now),
|
||||
tags: computeTagSummary(entries, now),
|
||||
structuredTags: computeStructuredTagSummary(entries, now),
|
||||
notesTimeline: computeNotesTimeline(entries),
|
||||
correlations: {
|
||||
sleep: computeMoodMetricCorrelation(
|
||||
moodDaily,
|
||||
@@ -473,9 +632,45 @@ export async function fetchMoodAggregates(
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: windowCutoff } },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
take: 2000,
|
||||
select: { date: true, score: true, tags: true, moodLoggedAt: true },
|
||||
select: {
|
||||
date: true,
|
||||
score: true,
|
||||
tags: true,
|
||||
moodLoggedAt: true,
|
||||
mood: true,
|
||||
note: true,
|
||||
// v1.8.5 — pull the structured-tag links + their catalog rows so
|
||||
// the breakdown can fold structured tags next to the flat ones.
|
||||
tagLinks: {
|
||||
select: {
|
||||
moodTag: {
|
||||
select: {
|
||||
key: true,
|
||||
labelKey: true,
|
||||
icon: true,
|
||||
category: { select: { key: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
.then((rows) =>
|
||||
rows.reverse().map((row) => ({
|
||||
date: row.date,
|
||||
score: row.score,
|
||||
tags: parseTags(row.tags),
|
||||
moodLoggedAt: row.moodLoggedAt,
|
||||
mood: row.mood,
|
||||
note: row.note,
|
||||
structuredTags: row.tagLinks.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
categoryKey: link.moodTag.category.key,
|
||||
labelKey: link.moodTag.labelKey,
|
||||
icon: link.moodTag.icon,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
||||
const measurements = await prisma.measurement
|
||||
.findMany({
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* v1.8.5 — structured-tag link helpers.
|
||||
*
|
||||
* Resolve a list of catalog tag keys (`mood_tags.key`) to their ids and
|
||||
* write the `mood_entry_tag_links` join for a mood entry. The catalog is
|
||||
* the source of truth: unknown keys are dropped silently so a stale
|
||||
* client can never mint a link to a tag the deployment doesn't carry.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve catalog tag keys to ids, dropping unknown / inactive keys.
|
||||
* Returns the ids in catalog order (deduped).
|
||||
*/
|
||||
export async function resolveTagKeysToIds(keys: string[]): Promise<string[]> {
|
||||
const unique = Array.from(new Set(keys));
|
||||
if (unique.length === 0) return [];
|
||||
const rows = await prisma.moodTag.findMany({
|
||||
where: { key: { in: unique }, isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the structured-tag links for a freshly-created entry. No-op on
|
||||
* an empty / all-unknown key set.
|
||||
*/
|
||||
export async function createTagLinks(
|
||||
moodEntryId: string,
|
||||
keys: string[],
|
||||
): Promise<void> {
|
||||
const tagIds = await resolveTagKeysToIds(keys);
|
||||
if (tagIds.length === 0) return;
|
||||
await prisma.moodEntryTagLink.createMany({
|
||||
data: tagIds.map((moodTagId) => ({ moodEntryId, moodTagId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the full structured-tag link set for an entry. `keys` is the
|
||||
* desired set; the helper deletes links no longer present and inserts
|
||||
* the new ones, leaving unchanged links in place. Passing an empty array
|
||||
* clears every link.
|
||||
*/
|
||||
export async function replaceTagLinks(
|
||||
moodEntryId: string,
|
||||
keys: string[],
|
||||
): Promise<void> {
|
||||
const desiredIds = new Set(await resolveTagKeysToIds(keys));
|
||||
const existing = await prisma.moodEntryTagLink.findMany({
|
||||
where: { moodEntryId },
|
||||
select: { moodTagId: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((row) => row.moodTagId));
|
||||
|
||||
const toDelete = [...existingIds].filter((id) => !desiredIds.has(id));
|
||||
const toCreate = [...desiredIds].filter((id) => !existingIds.has(id));
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
await prisma.moodEntryTagLink.deleteMany({
|
||||
where: { moodEntryId, moodTagId: { in: toDelete } },
|
||||
});
|
||||
}
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.moodEntryTagLink.createMany({
|
||||
data: toCreate.map((moodTagId) => ({ moodEntryId, moodTagId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* v1.8.5 — default mood-tag taxonomy.
|
||||
*
|
||||
* The canonical Category → Tag catalog seeded into `mood_tag_categories`
|
||||
* + `mood_tags` by migration `0101_v185_mood_tag_taxonomy`. Modelled on
|
||||
* the standalone mood diary's structured taxonomy but keyed on i18n
|
||||
* message keys (resolved against all six locales) rather than hard-coded
|
||||
* labels, so the catalog renders localised everywhere.
|
||||
*
|
||||
* The ids are deterministic string literals so the migration seed is
|
||||
* idempotent (re-running upserts by `key`) and so the catalog rows are
|
||||
* stable across deployments. Keys are stable machine identifiers; the
|
||||
* `labelKey` is the i18n key the capture UI + breakdown resolve.
|
||||
*
|
||||
* This is purely additive next to the legacy flat `MoodEntry.tags` JSON
|
||||
* column — the structured taxonomy is a second capture surface, not a
|
||||
* replacement.
|
||||
*/
|
||||
|
||||
export interface MoodTagSeed {
|
||||
/** Stable machine key, unique across the catalog. */
|
||||
key: string;
|
||||
/** i18n message key `mood.tag.<key>`. */
|
||||
labelKey: string;
|
||||
/** Lucide icon name. */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface MoodTagCategorySeed {
|
||||
key: string;
|
||||
/** i18n message key `mood.tagCategory.<key>`. */
|
||||
labelKey: string;
|
||||
/** Lucide icon name for the category header. */
|
||||
icon: string;
|
||||
tags: MoodTagSeed[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The seeded taxonomy. Order within the arrays is the `sortOrder` used
|
||||
* for the catalog rows (category index, tag index within its category).
|
||||
*/
|
||||
export const MOOD_TAG_TAXONOMY: MoodTagCategorySeed[] = [
|
||||
{
|
||||
key: "feelings",
|
||||
labelKey: "mood.tagCategory.feelings",
|
||||
icon: "Heart",
|
||||
tags: [
|
||||
{ key: "happy", labelKey: "mood.tag.happy", icon: "Smile" },
|
||||
{ key: "excited", labelKey: "mood.tag.excited", icon: "Zap" },
|
||||
{ key: "grateful", labelKey: "mood.tag.grateful", icon: "HandHeart" },
|
||||
{ key: "relaxed", labelKey: "mood.tag.relaxed", icon: "CloudSun" },
|
||||
{ key: "content", labelKey: "mood.tag.content", icon: "ThumbsUp" },
|
||||
{ key: "tired", labelKey: "mood.tag.tired", icon: "Moon" },
|
||||
{ key: "unsure", labelKey: "mood.tag.unsure", icon: "HelpCircle" },
|
||||
{ key: "bored", labelKey: "mood.tag.bored", icon: "Meh" },
|
||||
{ key: "tense", labelKey: "mood.tag.tense", icon: "AlertTriangle" },
|
||||
{ key: "angry", labelKey: "mood.tag.angry", icon: "Flame" },
|
||||
{ key: "stressed", labelKey: "mood.tag.stressed", icon: "Brain" },
|
||||
{ key: "sad", labelKey: "mood.tag.sad", icon: "Frown" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sleep",
|
||||
labelKey: "mood.tagCategory.sleep",
|
||||
icon: "BedDouble",
|
||||
tags: [
|
||||
{ key: "slept_well", labelKey: "mood.tag.sleptWell", icon: "Moon" },
|
||||
{ key: "slept_ok", labelKey: "mood.tag.sleptOk", icon: "CloudMoon" },
|
||||
{ key: "slept_poorly", labelKey: "mood.tag.sleptPoorly", icon: "MoonStar" },
|
||||
{ key: "early_night", labelKey: "mood.tag.earlyNight", icon: "Clock" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "health",
|
||||
labelKey: "mood.tagCategory.health",
|
||||
icon: "HeartPulse",
|
||||
tags: [
|
||||
{ key: "worked_out", labelKey: "mood.tag.workedOut", icon: "Dumbbell" },
|
||||
{ key: "ate_well", labelKey: "mood.tag.ateWell", icon: "Apple" },
|
||||
{ key: "hydrated", labelKey: "mood.tag.hydrated", icon: "GlassWater" },
|
||||
{ key: "walked", labelKey: "mood.tag.walked", icon: "Footprints" },
|
||||
{ key: "alcohol", labelKey: "mood.tag.alcohol", icon: "Wine" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "social",
|
||||
labelKey: "mood.tagCategory.social",
|
||||
icon: "Users",
|
||||
tags: [
|
||||
{ key: "family", labelKey: "mood.tag.family", icon: "Home" },
|
||||
{ key: "friends", labelKey: "mood.tag.friends", icon: "Users" },
|
||||
{ key: "party", labelKey: "mood.tag.party", icon: "PartyPopper" },
|
||||
{ key: "alone", labelKey: "mood.tag.alone", icon: "User" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "work",
|
||||
labelKey: "mood.tagCategory.work",
|
||||
icon: "Briefcase",
|
||||
tags: [
|
||||
{ key: "productive", labelKey: "mood.tag.productive", icon: "CheckCircle" },
|
||||
{ key: "overtime", labelKey: "mood.tag.overtime", icon: "Clock" },
|
||||
{ key: "day_off", labelKey: "mood.tag.dayOff", icon: "LogOut" },
|
||||
{ key: "travel", labelKey: "mood.tag.travel", icon: "Plane" },
|
||||
{ key: "sick_day", labelKey: "mood.tag.sickDay", icon: "Thermometer" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Flat list of every seeded tag key (for validation / lookups). */
|
||||
export const MOOD_TAG_KEYS: string[] = MOOD_TAG_TAXONOMY.flatMap((c) =>
|
||||
c.tags.map((tagSeed) => tagSeed.key),
|
||||
);
|
||||
@@ -60,6 +60,14 @@ export const queryKeys = {
|
||||
* `moodDependentKeys` fan-out below.
|
||||
*/
|
||||
moodInsights: () => ["mood-insights"] as const,
|
||||
/**
|
||||
* v1.8.5 — structured mood-tag taxonomy catalog (global reference
|
||||
* data, identical for every user). Read by the mood-logging form's
|
||||
* tag-category capture surface. Not invalidated on a mood write — the
|
||||
* catalog only changes on a migration / admin edit, so a long
|
||||
* `staleTime` is fine.
|
||||
*/
|
||||
moodTagCatalog: () => ["mood-tag-catalog"] as const,
|
||||
|
||||
/**
|
||||
* v1.7.0 W6 — unified dashboard first-paint snapshot. One client cell
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { moodLogCredentialsSchema } from "../moodlog";
|
||||
import {
|
||||
moodLogCredentialsSchema,
|
||||
createMoodEntrySchema,
|
||||
updateMoodEntrySchema,
|
||||
} from "../moodlog";
|
||||
|
||||
describe("createMoodEntrySchema — note + structured tagKeys (v1.8.5)", () => {
|
||||
const base = { mood: "GUT", moodLoggedAt: "2026-06-01T12:00:00.000Z" };
|
||||
|
||||
it("round-trips the note field", () => {
|
||||
const r = createMoodEntrySchema.safeParse({ ...base, note: "felt great" });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.note).toBe("felt great");
|
||||
});
|
||||
|
||||
it("accepts structured tagKeys alongside flat tags", () => {
|
||||
const r = createMoodEntrySchema.safeParse({
|
||||
...base,
|
||||
tags: ["nausea"],
|
||||
tagKeys: ["happy", "worked_out"],
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.tagKeys).toEqual(["happy", "worked_out"]);
|
||||
expect(r.data.tags).toEqual(["nausea"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an over-long note and an over-large tagKeys set", () => {
|
||||
expect(
|
||||
createMoodEntrySchema.safeParse({ ...base, note: "x".repeat(501) }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
createMoodEntrySchema.safeParse({
|
||||
...base,
|
||||
tagKeys: Array.from({ length: 31 }, (_, i) => `t${i}`),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows update to clear tagKeys with null", () => {
|
||||
const r = updateMoodEntrySchema.safeParse({ tagKeys: null });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.tagKeys).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("moodLogCredentialsSchema SSRF guard", () => {
|
||||
it("accepts a public moodLog URL", () => {
|
||||
|
||||
@@ -78,9 +78,19 @@ export function getScoreForMood(mood: string): number {
|
||||
return MOOD_SCORE_MAP[mood] ?? 3;
|
||||
}
|
||||
|
||||
// v1.8.5 — structured-tag keys picked from the catalog (`mood_tags.key`).
|
||||
// Additive alongside the flat free-text `tags`: an entry can carry both.
|
||||
// Bounded so a single create can't fan out an unbounded link set.
|
||||
const structuredTagKeys = z.array(z.string().max(60)).max(30);
|
||||
|
||||
export const createMoodEntrySchema = z.object({
|
||||
mood: moodLevelEnum,
|
||||
tags: z.array(z.string().max(50)).max(20).optional(),
|
||||
// v1.8.5 — structured-tag keys from the taxonomy. Server resolves each
|
||||
// key to a `MoodTag` row and writes the `MoodEntryTagLink` join;
|
||||
// unknown keys are dropped silently (the catalog is the source of
|
||||
// truth, a stale client can't mint a tag).
|
||||
tagKeys: structuredTagKeys.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.
|
||||
@@ -92,6 +102,9 @@ export const createMoodEntrySchema = z.object({
|
||||
export const updateMoodEntrySchema = z.object({
|
||||
mood: moodLevelEnum.optional(),
|
||||
tags: z.array(z.string().max(50)).max(20).nullable().optional(),
|
||||
// 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(),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
moodLoggedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
|
||||
Reference in new issue
Block a user