fix(mood): entry edits spare archived-tag history and insights speak the custom labels

Editing any field of a mood entry re-sent its tag set, and archived
tag keys can no longer resolve — the diff deleted the entry's links to
them invisibly, against the archive contract the tag suite just
shipped. Submitted tag keys now govern active links only; links to
archived tags survive every edit, and the explicit purge stays the one
removal path. The insights surfaces — tag breakdown, influence, better
days, crosstab, the model snapshot and the narratives — render the
decrypted custom-tag label instead of the raw key, decrypted once per
tag server-side.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 22:55:50 +02:00
1 parent f3dda21917
commit 97822e9e6d
14 files changed
+372 -22

No files matched your search

@@ -55,6 +55,25 @@ describe("<MoodBetterDays>", () => {
expect(html).toContain('data-direction="up"');
});
it("prefers a custom tag's decrypted label over its raw labelKey", () => {
const html = render(
<MoodBetterDays
factors={[
{
...tagFactor,
key: "custom:abc-123",
labelKey: "custom:abc-123",
label: "Migraine",
categoryKey: "custom",
icon: "Tag",
},
]}
/>,
);
expect(html).toContain("Migraine");
expect(html).not.toContain("custom:abc-123");
});
it("shows a metric factor with its labelled name and r value", () => {
const html = render(<MoodBetterDays factors={[metricFactor]} />);
// sleep maps to the localized correlation title, not the raw key
@@ -79,6 +79,26 @@ describe("<MoodTagInfluence>", () => {
expect(html).not.toContain("charts.weekdaysFull.mon");
});
it("prefers a custom tag's decrypted label over its raw labelKey", () => {
// A custom tag's labelKey mirrors its raw `custom:<uuid>` key — the
// decrypted label must win or the row prints the uuid.
const html = render(
<MoodTagInfluence
rows={[
flatRow({
tag: "custom:abc-123",
labelKey: "custom:abc-123",
label: "Migraine",
categoryKey: "custom",
icon: "Tag",
}),
]}
/>,
);
expect(html).toContain("Migraine");
expect(html).not.toContain("custom:abc-123");
});
it("does not render a local generic disclaimer", () => {
const html = render(
<MoodTagInfluence rows={[flatRow(), flatRow({ tag: "social" })]} />,
@@ -23,6 +23,13 @@ export interface MoodBetterDayFactor {
source: "tag" | "metric";
key: string;
labelKey: string | null;
/**
* v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
* tag's `labelKey` mirrors its raw `custom:<uuid>` key, so the label
* takes precedence over `t(labelKey)`. Null for catalogue / flat /
* metric rows.
*/
label?: string | null;
categoryKey: string | null;
icon: string | null;
direction: "up" | "down";
@@ -78,6 +85,11 @@ export function MoodBetterDays({
let Icon: ReturnType<typeof moodTagIcon> | null = null;
if (factor.source === "metric") {
label = t(METRIC_LABEL_KEY[factor.key] ?? factor.key);
} else if (factor.label) {
// Custom tag — render the decrypted label verbatim (its
// labelKey is the raw `custom:<uuid>` key).
label = factor.label;
Icon = moodTagIcon(factor.icon);
} else if (factor.labelKey) {
label = t(factor.labelKey);
Icon = moodTagIcon(factor.icon);
@@ -16,6 +16,12 @@ export interface MoodStructuredTagRow {
key: string;
categoryKey: string;
labelKey: string;
/**
* v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
* tag's `labelKey` mirrors its raw `custom:<uuid>` key, so the label
* takes precedence over `t(labelKey)`. Null for catalogue tags.
*/
label?: string | null;
icon: string | null;
count: number;
avgScore: number;
@@ -47,7 +53,7 @@ export function MoodStructuredTagBreakdown({
aria-hidden="true"
/>
<span className="text-foreground w-28 shrink-0 truncate">
{t(row.labelKey)}
{row.label ?? t(row.labelKey)}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
{/* v1.16.8 — `opacity-55` matches the `fillOpacity={0.55}`
@@ -21,6 +21,12 @@ export type MoodInfluenceConfidence = "low" | "medium" | "high";
export interface MoodTagInfluenceRow {
tag: string;
labelKey: string | null;
/**
* v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
* tag's `labelKey` mirrors its raw `custom:<uuid>` key, so the label
* takes precedence over `t(labelKey)`. Null for catalogue / flat tags.
*/
label?: string | null;
categoryKey: string | null;
icon: string | null;
withDays: number;
@@ -67,7 +73,8 @@ export function MoodTagInfluence({
<ul className="divide-border divide-y">
{rows.map((row) => {
const Icon = row.labelKey ? moodTagIcon(row.icon) : null;
const label = row.labelKey ? t(row.labelKey) : row.tag;
const label =
row.label ?? (row.labelKey ? t(row.labelKey) : row.tag);
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${row.delta.toFixed(1)}`;
return (
@@ -24,6 +24,12 @@ export type MoodCrosstabMode = "sameDay" | "nextDay";
export interface MoodTagMetricCrosstabRow {
tag: string;
labelKey: string;
/**
* v1.16.11 — decrypted custom-tag label, resolved server-side. A custom
* tag's `labelKey` mirrors its raw `custom:<uuid>` key, so the label
* takes precedence over `t(labelKey)`. Null for catalogue tags.
*/
label?: string | null;
categoryKey: string;
icon: string | null;
metricKey: string;
@@ -88,7 +94,7 @@ export function MoodTagMetricCrosstab({
<ul className="divide-border divide-y">
{rows.map((row) => {
const Icon = moodTagIcon(row.icon);
const tagLabel = t(row.labelKey);
const tagLabel = row.label ?? t(row.labelKey);
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);