Write mood insight figures with the reader's decimal separator

Averages, deltas and correlation figures on the mood insights went
through toFixed and read '3.4' in German. They use the locale formatter
now, and the whole-number crosstab cells pick up thousands grouping.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 19:04:30 +02:00
1 parent 7d738c0e49
commit 8302f5f62a
11 files changed
+57 -36

No files matched your search

@@ -2,7 +2,7 @@
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
@@ -70,6 +70,7 @@ export function MoodBetterDays({
factors: MoodBetterDayFactor[];
}) {
const { t } = useTranslations();
const nf = useFormatters();
if (factors.length === 0) return null;
return (
@@ -108,9 +109,9 @@ export function MoodBetterDays({
// metric factors show the correlation coefficient.
const effectText =
factor.source === "tag" && factor.delta != null
? `${factor.delta >= 0 ? "+" : ""}${factor.delta.toFixed(1)}`
? `${factor.delta >= 0 ? "+" : ""}${nf.number(factor.delta, 1)}`
: factor.r != null
? `r ${factor.r.toFixed(2)}`
? `r ${nf.number(factor.r, 2)}`
: "";
return (
@@ -16,7 +16,7 @@
* them; a second threshold in the presentation layer would be a second place
* to get the statistics wrong.
*/
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { contextValueLabelKey } from "@/lib/mood/context-vocabulary";
import type { ContextComparisonRow } from "@/lib/insights/mood-context-crosstab";
@@ -26,6 +26,7 @@ export function MoodContextComparison({
rows: ContextComparisonRow[];
}) {
const { t } = useTranslations();
const nf = useFormatters();
if (rows.length === 0) return null;
return (
@@ -50,8 +51,8 @@ export function MoodContextComparison({
{t("insights.mood.contextComparison.statement", {
label,
direction,
withAvg: row.withAvg.toFixed(1),
withoutAvg: row.withoutAvg.toFixed(1),
withAvg: nf.number(row.withAvg, 1),
withoutAvg: nf.number(row.withoutAvg, 1),
})}
</p>
{/* The counts ride with the statement, in the same block a
@@ -8,7 +8,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { CorrelationResult } from "@/lib/analytics/correlations";
import { MoodExplainerIcon } from "./mood-explainer-icon";
@@ -106,6 +106,7 @@ function MoodCorrelationCard({
className?: string;
}) {
const { t } = useTranslations();
const nf = useFormatters();
const hasResult = data.result != null;
// Three different silences, and the reader deserves to know which one they
@@ -164,7 +165,7 @@ function MoodCorrelationCard({
label={t("insights.mood.correlation.sourceLabel")}
detail={t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
r: nf.number(data.result.r, 2),
})}
/>
</div>
@@ -179,7 +180,7 @@ function MoodCorrelationCard({
label={t("insights.mood.correlation.sourceLabel")}
detail={t("insights.mood.correlation.source", {
n: data.n,
r: data.result.r.toFixed(2),
r: nf.number(data.result.r, 2),
})}
/>
) : null}
@@ -1,6 +1,6 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { MoodExplainerIcon } from "./mood-explainer-icon";
import {
PatternDismissButton,
@@ -94,6 +94,7 @@ export function MoodDiscoveredRelations({
pairsTested: number;
}) {
const { t } = useTranslations();
const nf = useFormatters();
const dismissal = usePatternDismissalOverrides();
if (pairs.length === 0) return null;
@@ -169,8 +170,8 @@ export function MoodDiscoveredRelations({
label={t("insights.mood.discovery.statLabel")}
detail={t("insights.mood.discovery.stat", {
n: pair.n,
r: pair.r.toFixed(2),
q: pair.qValue.toFixed(3),
r: nf.number(pair.r, 2),
q: nf.number(pair.qValue, 3),
})}
/>
</span>
@@ -1,6 +1,7 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
@@ -84,8 +85,12 @@ const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
};
/** Whole numbers for steps; one decimal otherwise. */
function fmt(value: number, display: MoodFactorCrosstabDisplay): string {
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
function formatValue(
value: number,
display: MoodFactorCrosstabDisplay,
nf: Formatters,
): string {
return display === "steps" ? nf.integer(value) : nf.number(value, 1);
}
export function MoodFactorMetricCrosstab({
@@ -94,6 +99,7 @@ export function MoodFactorMetricCrosstab({
rows: MoodFactorMetricCrosstabRow[];
}) {
const { t } = useTranslations();
const nf = useFormatters();
const unitDisplay = useUnitDisplay();
const dismissal = usePatternDismissalOverrides();
if (rows.length === 0) return null;
@@ -143,7 +149,7 @@ export function MoodFactorMetricCrosstab({
// green/red would imply a health verdict the data doesn't support.
// The sign prefix + `data-direction` carry the direction.
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${fmt(conv(row.delta), row.display)} ${unit}`;
const deltaText = `${up ? "+" : ""}${formatValue(conv(row.delta), row.display, nf)} ${unit}`;
return (
<li
key={`${row.metricKey}:${row.factor}`}
@@ -197,8 +203,8 @@ export function MoodFactorMetricCrosstab({
{
factor: factorLabel,
metric: metricLabel,
lowAvg: fmt(conv(row.lowAvg), row.display),
highAvg: fmt(conv(row.highAvg), row.display),
lowAvg: formatValue(conv(row.lowAvg), row.display, nf),
highAvg: formatValue(conv(row.highAvg), row.display, nf),
unit,
lowDays: row.lowDays,
},
@@ -1,6 +1,6 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
/**
@@ -40,6 +40,7 @@ export function MoodStructuredTagBreakdown({
tags: MoodStructuredTagRow[];
}) {
const { t } = useTranslations();
const nf = useFormatters();
const maxCount = tags.reduce((m, row) => Math.max(m, row.count), 0) || 1;
return (
@@ -74,7 +75,7 @@ export function MoodStructuredTagBreakdown({
style={{ color: colorForScore(row.avgScore) }}
title={t("insights.mood.tagAvgMood")}
>
{row.avgScore.toFixed(1)}
{nf.number(row.avgScore, 1)}
</span>
</li>
);
@@ -1,6 +1,6 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
/**
* v1.8.5 — tag / trigger breakdown.
@@ -30,6 +30,7 @@ function colorForScore(score: number): string {
export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) {
const { t } = useTranslations();
const nf = useFormatters();
const maxCount = tags.reduce((m, row) => Math.max(m, row.count), 0) || 1;
return (
@@ -61,7 +62,7 @@ export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) {
style={{ color: colorForScore(row.avgScore) }}
title={t("insights.mood.tagAvgMood")}
>
{row.avgScore.toFixed(1)}
{nf.number(row.avgScore, 1)}
</span>
</li>
))}
@@ -1,6 +1,6 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
@@ -62,6 +62,7 @@ function deltaColor(delta: number): string {
export function MoodTagInfluence({ rows }: { rows: MoodTagInfluenceRow[] }) {
const { t } = useTranslations();
const nf = useFormatters();
if (rows.length === 0) return null;
return (
@@ -71,7 +72,7 @@ export function MoodTagInfluence({ rows }: { rows: MoodTagInfluenceRow[] }) {
const Icon = row.labelKey ? moodTagIcon(row.icon) : null;
const label = row.label ?? (row.labelKey ? t(row.labelKey) : row.tag);
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${row.delta.toFixed(1)}`;
const deltaText = `${up ? "+" : ""}${nf.number(row.delta, 1)}`;
return (
<li
key={`${row.labelKey ?? "flat"}:${row.tag}`}
@@ -110,8 +111,8 @@ export function MoodTagInfluence({ rows }: { rows: MoodTagInfluenceRow[] }) {
</div>
<p className="text-muted-foreground text-xs">
{t("insights.mood.influence.detail", {
withAvg: row.withAvg.toFixed(1),
withoutAvg: row.withoutAvg.toFixed(1),
withAvg: nf.number(row.withAvg, 1),
withoutAvg: nf.number(row.withoutAvg, 1),
withDays: row.withDays,
})}
</p>
@@ -1,6 +1,7 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
import { cn } from "@/lib/utils";
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
@@ -92,8 +93,12 @@ const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
};
/** One decimal for hours/score, whole numbers for kcal. */
function fmt(value: number, display: MoodCrosstabDisplay): string {
return display === "kcal" ? Math.round(value).toString() : value.toFixed(1);
function formatValue(
value: number,
display: MoodCrosstabDisplay,
nf: Formatters,
): string {
return display === "kcal" ? nf.integer(value) : nf.number(value, 1);
}
export function MoodTagMetricCrosstab({
@@ -102,6 +107,7 @@ export function MoodTagMetricCrosstab({
rows: MoodTagMetricCrosstabRow[];
}) {
const { t } = useTranslations();
const nf = useFormatters();
const dismissal = usePatternDismissalOverrides();
if (rows.length === 0) return null;
@@ -135,7 +141,7 @@ export function MoodTagMetricCrosstab({
);
const unit = t(UNIT_KEY[row.display]);
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
const deltaText = `${up ? "+" : ""}${formatValue(row.delta, row.display, nf)} ${unit}`;
return (
<li
key={`${row.metricKey}:${row.tag}`}
@@ -187,8 +193,8 @@ export function MoodTagMetricCrosstab({
? "insights.mood.crosstab.detailNextDay"
: "insights.mood.crosstab.detailSameDay",
{
withAvg: fmt(row.withAvg, row.display),
withoutAvg: fmt(row.withoutAvg, row.display),
withAvg: formatValue(row.withAvg, row.display, nf),
withoutAvg: formatValue(row.withoutAvg, row.display, nf),
unit,
withDays: row.withDays,
},
@@ -11,7 +11,7 @@ import {
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
/**
* v1.9.0 — average mood per part of day.
@@ -62,6 +62,7 @@ export function MoodTimeOfDayChart({
pattern: MoodTimeOfDayPattern;
}) {
const { t } = useTranslations();
const nf = useFormatters();
const data = pattern.buckets.map((row) => ({
bucket: row.bucket,
@@ -125,7 +126,7 @@ export function MoodTimeOfDayChart({
return [t("insights.mood.weekdayNoData"), ""];
}
return [
`${payload.avgScore.toFixed(1)} (${payload.count})`,
`${nf.number(payload.avgScore, 1)} (${payload.count})`,
t("insights.mood.timeOfDay.title"),
];
}}
@@ -12,7 +12,7 @@ import {
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
/**
* v1.8.5 — average mood per weekday (Monday-aligned).
@@ -50,6 +50,7 @@ function colorForScore(score: number): string {
export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
const { t } = useTranslations();
const nf = useFormatters();
const data = weekday.map((row) => ({
weekday: row.weekday,
@@ -134,7 +135,7 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
return [t("insights.mood.weekdayNoData"), ""];
}
return [
`${payload.avgScore.toFixed(1)} (${payload.count})`,
`${nf.number(payload.avgScore, 1)} (${payload.count})`,
t("insights.mood.weekdayTitle"),
];
}}