mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(insights): surface mood time-of-day, stability, and weight/BP correlations
Render the new mood analyses on the insights surface: - a four-bar time-of-day chart (morning / afternoon / evening / night), shown only when the server marks the pattern reliable - a stability tile beside the in-target tile, with the descriptive band - the time-of-day takeaway in the narrative feed (new Clock icon, daypart label resolved from the bundle) Also wire the mood x weight and mood x systolic-blood-pressure scatter cards. Their Pearson coefficients and points were already computed and returned by the endpoint but never rendered; they now sit alongside the sleep / activity / pulse cards, each gated on the same minimum-pairs threshold so a thin pairing renders an empty state rather than a noisy r. New i18n keys land EN-first and propagate across de / es / fr / it / pl. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 12902b0cdbc6aa272b6da3c35ba3781f9216cf9f)
This commit is contained in:
1 parent
141c083ff6
commit
f65181bbf2
12 files changed
+405
-14
No files matched your search
+22
-2
@@ -2412,7 +2412,11 @@
|
||||
"strengthNone": "Keine",
|
||||
"source": "{n} gepaarte Tage · r = {r}",
|
||||
"emptyTitle": "Noch nicht genug gepaarte Daten",
|
||||
"emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen."
|
||||
"emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.",
|
||||
"weightTitle": "Stimmung vs. Gewicht",
|
||||
"bloodPressureTitle": "Stimmung vs. Blutdruck",
|
||||
"weightAxis": "Gewicht",
|
||||
"bloodPressureAxis": "Systolisch (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Tag-Auswertung",
|
||||
"inTargetValue": "{pct} %",
|
||||
@@ -2428,7 +2432,23 @@
|
||||
"tagLift": "Tage mit „{tag}“ liegen im Schnitt {delta} über deiner allgemeinen Stimmung.",
|
||||
"tagDrop": "Tage mit „{tag}“ liegen {delta} unter deinem Durchschnitt.",
|
||||
"inTarget": "Du warst an {pct} % der letzten 30 Tage in deinem guten Stimmungsbereich.",
|
||||
"streak": "Du hast deine Stimmung {days} Tage in Folge erfasst – weiter so."
|
||||
"streak": "Du hast deine Stimmung {days} Tage in Folge erfasst – weiter so.",
|
||||
"timeOfDay": "Am {bucket} fühlst du dich am besten, im Schnitt {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Stimmung nach Tageszeit",
|
||||
"morning": "Morgen",
|
||||
"afternoon": "Nachmittag",
|
||||
"evening": "Abend",
|
||||
"night": "Nacht",
|
||||
"bestCaption": "Am {bucket} fühlst du dich tendenziell am besten."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Deine Stimmung war sehr gleichmäßig",
|
||||
"bandSteady": "Deine Stimmung war recht gleichmäßig",
|
||||
"bandVariable": "Deine Stimmung war merklich schwankend",
|
||||
"bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+22
-2
@@ -2412,7 +2412,11 @@
|
||||
"strengthNone": "None",
|
||||
"source": "{n} paired days · r = {r}",
|
||||
"emptyTitle": "Not enough paired data yet",
|
||||
"emptyDescription": "Log mood and this metric on the same days to see a correlation."
|
||||
"emptyDescription": "Log mood and this metric on the same days to see a correlation.",
|
||||
"weightTitle": "Mood vs. weight",
|
||||
"bloodPressureTitle": "Mood vs. blood pressure",
|
||||
"weightAxis": "Weight",
|
||||
"bloodPressureAxis": "Systolic (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Tag breakdown",
|
||||
"inTargetValue": "{pct}%",
|
||||
@@ -2428,7 +2432,23 @@
|
||||
"tagLift": "Days tagged \"{tag}\" average {delta} above your overall mood.",
|
||||
"tagDrop": "Days tagged \"{tag}\" run {delta} below your average.",
|
||||
"inTarget": "You've been in your good-mood range {pct}% of the last 30 days.",
|
||||
"streak": "You've logged your mood {days} days in a row — keep it up."
|
||||
"streak": "You've logged your mood {days} days in a row — keep it up.",
|
||||
"timeOfDay": "You feel best in the {bucket}, averaging {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Mood by time of day",
|
||||
"morning": "Morning",
|
||||
"afternoon": "Afternoon",
|
||||
"evening": "Evening",
|
||||
"night": "Night",
|
||||
"bestCaption": "You tend to feel best in the {bucket}."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Your mood has been very steady",
|
||||
"bandSteady": "Your mood has been fairly steady",
|
||||
"bandVariable": "Your mood has been noticeably variable",
|
||||
"bandVeryVariable": "Your mood has swung a lot lately"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+22
-2
@@ -2412,7 +2412,11 @@
|
||||
"strengthNone": "Ninguna",
|
||||
"source": "{n} días emparejados · r = {r}",
|
||||
"emptyTitle": "Aún no hay suficientes datos emparejados",
|
||||
"emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación."
|
||||
"emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.",
|
||||
"weightTitle": "Ánimo vs. peso",
|
||||
"bloodPressureTitle": "Ánimo vs. presión arterial",
|
||||
"weightAxis": "Peso",
|
||||
"bloodPressureAxis": "Sistólica (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Desglose de etiquetas",
|
||||
"inTargetValue": "{pct} %",
|
||||
@@ -2428,7 +2432,23 @@
|
||||
"tagLift": "Los días con la etiqueta «{tag}» promedian {delta} por encima de tu ánimo general.",
|
||||
"tagDrop": "Los días con la etiqueta «{tag}» quedan {delta} por debajo de tu media.",
|
||||
"inTarget": "Has estado en tu rango de buen ánimo el {pct} % de los últimos 30 días.",
|
||||
"streak": "Has registrado tu ánimo {days} días seguidos: sigue así."
|
||||
"streak": "Has registrado tu ánimo {days} días seguidos: sigue así.",
|
||||
"timeOfDay": "Te sientes mejor por la {bucket}, con una media de {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Estado de ánimo por hora del día",
|
||||
"morning": "Mañana",
|
||||
"afternoon": "Tarde",
|
||||
"evening": "Noche",
|
||||
"night": "Madrugada",
|
||||
"bestCaption": "Sueles sentirte mejor por la {bucket}."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Tu estado de ánimo ha sido muy estable",
|
||||
"bandSteady": "Tu estado de ánimo ha sido bastante estable",
|
||||
"bandVariable": "Tu estado de ánimo ha sido notablemente variable",
|
||||
"bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+22
-2
@@ -2412,7 +2412,11 @@
|
||||
"strengthNone": "Aucune",
|
||||
"source": "{n} jours appariés · r = {r}",
|
||||
"emptyTitle": "Pas encore assez de données appariées",
|
||||
"emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation."
|
||||
"emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.",
|
||||
"weightTitle": "Humeur vs. poids",
|
||||
"bloodPressureTitle": "Humeur vs. tension artérielle",
|
||||
"weightAxis": "Poids",
|
||||
"bloodPressureAxis": "Systolique (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Répartition des étiquettes",
|
||||
"inTargetValue": "{pct} %",
|
||||
@@ -2428,7 +2432,23 @@
|
||||
"tagLift": "Les jours marqués « {tag} » dépassent votre humeur générale de {delta} en moyenne.",
|
||||
"tagDrop": "Les jours marqués « {tag} » sont {delta} sous votre moyenne.",
|
||||
"inTarget": "Vous avez été dans votre plage de bonne humeur {pct} % des 30 derniers jours.",
|
||||
"streak": "Vous avez noté votre humeur {days} jours d'affilée — continuez."
|
||||
"streak": "Vous avez noté votre humeur {days} jours d'affilée — continuez.",
|
||||
"timeOfDay": "Vous vous sentez mieux le {bucket}, en moyenne {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Humeur selon l'heure de la journée",
|
||||
"morning": "Matin",
|
||||
"afternoon": "Après-midi",
|
||||
"evening": "Soir",
|
||||
"night": "Nuit",
|
||||
"bestCaption": "Vous vous sentez généralement mieux le {bucket}."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Votre humeur a été très stable",
|
||||
"bandSteady": "Votre humeur a été plutôt stable",
|
||||
"bandVariable": "Votre humeur a été nettement variable",
|
||||
"bandVeryVariable": "Votre humeur a beaucoup fluctué récemment"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+22
-2
@@ -2412,7 +2412,11 @@
|
||||
"strengthNone": "Nessuna",
|
||||
"source": "{n} giorni appaiati · r = {r}",
|
||||
"emptyTitle": "Non ci sono ancora abbastanza dati appaiati",
|
||||
"emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione."
|
||||
"emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.",
|
||||
"weightTitle": "Umore vs. peso",
|
||||
"bloodPressureTitle": "Umore vs. pressione sanguigna",
|
||||
"weightAxis": "Peso",
|
||||
"bloodPressureAxis": "Sistolica (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Analisi etichette",
|
||||
"inTargetValue": "{pct} %",
|
||||
@@ -2428,7 +2432,23 @@
|
||||
"tagLift": "I giorni con l'etichetta «{tag}» superano in media il tuo umore generale di {delta}.",
|
||||
"tagDrop": "I giorni con l'etichetta «{tag}» restano {delta} sotto la tua media.",
|
||||
"inTarget": "Sei stato nel tuo intervallo di buon umore il {pct} % degli ultimi 30 giorni.",
|
||||
"streak": "Hai registrato il tuo umore per {days} giorni di fila — continua così."
|
||||
"streak": "Hai registrato il tuo umore per {days} giorni di fila — continua così.",
|
||||
"timeOfDay": "Ti senti meglio di {bucket}, con una media di {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Umore per fascia oraria",
|
||||
"morning": "Mattina",
|
||||
"afternoon": "Pomeriggio",
|
||||
"evening": "Sera",
|
||||
"night": "Notte",
|
||||
"bestCaption": "Tendi a sentirti meglio di {bucket}."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Il tuo umore è stato molto stabile",
|
||||
"bandSteady": "Il tuo umore è stato abbastanza stabile",
|
||||
"bandVariable": "Il tuo umore è stato notevolmente variabile",
|
||||
"bandVeryVariable": "Il tuo umore ha oscillato molto di recente"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+22
-2
@@ -2412,7 +2412,11 @@
|
||||
"strengthNone": "Brak",
|
||||
"source": "{n} sparowanych dni · r = {r}",
|
||||
"emptyTitle": "Za mało sparowanych danych",
|
||||
"emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację."
|
||||
"emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.",
|
||||
"weightTitle": "Nastrój vs. waga",
|
||||
"bloodPressureTitle": "Nastrój vs. ciśnienie krwi",
|
||||
"weightAxis": "Waga",
|
||||
"bloodPressureAxis": "Skurczowe (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Podział tagów",
|
||||
"inTargetValue": "{pct}%",
|
||||
@@ -2428,7 +2432,23 @@
|
||||
"tagLift": "Dni z tagiem „{tag}” są średnio o {delta} powyżej twojego ogólnego nastroju.",
|
||||
"tagDrop": "Dni z tagiem „{tag}” są o {delta} poniżej twojej średniej.",
|
||||
"inTarget": "Przez {pct}% ostatnich 30 dni byłeś w zakresie dobrego nastroju.",
|
||||
"streak": "Rejestrujesz nastrój od {days} dni z rzędu — tak trzymaj."
|
||||
"streak": "Rejestrujesz nastrój od {days} dni z rzędu — tak trzymaj.",
|
||||
"timeOfDay": "Najlepiej czujesz się {bucket}, średnio {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Nastrój według pory dnia",
|
||||
"morning": "Rano",
|
||||
"afternoon": "Popołudnie",
|
||||
"evening": "Wieczór",
|
||||
"night": "Noc",
|
||||
"bestCaption": "Zwykle najlepiej czujesz się {bucket}."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Twój nastrój był bardzo stabilny",
|
||||
"bandSteady": "Twój nastrój był dość stabilny",
|
||||
"bandVariable": "Twój nastrój był zauważalnie zmienny",
|
||||
"bandVeryVariable": "Twój nastrój ostatnio mocno się wahał"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -32,6 +32,13 @@ type Response = {
|
||||
heatmap: { windowDays: number; cells: [] };
|
||||
distribution: [];
|
||||
weekday: [];
|
||||
timeOfDay: {
|
||||
buckets: [];
|
||||
reliable: boolean;
|
||||
best: null;
|
||||
worst: null;
|
||||
};
|
||||
stability: null;
|
||||
tags: [];
|
||||
structuredTags: [];
|
||||
narratives: Array<{
|
||||
@@ -43,6 +50,8 @@ type Response = {
|
||||
sleep: EmptyCorrelation;
|
||||
steps: EmptyCorrelation;
|
||||
pulse: EmptyCorrelation;
|
||||
weight: EmptyCorrelation;
|
||||
bloodPressureSystolic: EmptyCorrelation;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -70,6 +79,8 @@ function baseData(inTargetPct: number | null): Response {
|
||||
heatmap: { windowDays: 30, cells: [] },
|
||||
distribution: [],
|
||||
weekday: [],
|
||||
timeOfDay: { buckets: [], reliable: false, best: null, worst: null },
|
||||
stability: null,
|
||||
tags: [],
|
||||
structuredTags: [],
|
||||
narratives: [
|
||||
@@ -88,6 +99,8 @@ function baseData(inTargetPct: number | null): Response {
|
||||
sleep: emptyCorrelation,
|
||||
steps: emptyCorrelation,
|
||||
pulse: emptyCorrelation,
|
||||
weight: emptyCorrelation,
|
||||
bloodPressureSystolic: emptyCorrelation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,24 +45,30 @@ export interface MoodMetricCorrelationData {
|
||||
n: number;
|
||||
}
|
||||
|
||||
type MetricKind = "sleep" | "steps" | "pulse";
|
||||
type MetricKind = "sleep" | "steps" | "pulse" | "weight" | "bloodPressure";
|
||||
|
||||
const TITLE_KEY: Record<MetricKind, string> = {
|
||||
sleep: "insights.mood.correlation.sleepTitle",
|
||||
steps: "insights.mood.correlation.stepsTitle",
|
||||
pulse: "insights.mood.correlation.pulseTitle",
|
||||
weight: "insights.mood.correlation.weightTitle",
|
||||
bloodPressure: "insights.mood.correlation.bloodPressureTitle",
|
||||
};
|
||||
|
||||
const Y_LABEL_KEY: Record<MetricKind, string> = {
|
||||
sleep: "insights.mood.correlation.sleepAxis",
|
||||
steps: "insights.mood.correlation.stepsAxis",
|
||||
pulse: "insights.mood.correlation.pulseAxis",
|
||||
weight: "insights.mood.correlation.weightAxis",
|
||||
bloodPressure: "insights.mood.correlation.bloodPressureAxis",
|
||||
};
|
||||
|
||||
const FILL_BY_KIND: Record<MetricKind, string> = {
|
||||
sleep: "var(--dracula-purple)",
|
||||
steps: "var(--dracula-cyan)",
|
||||
pulse: "var(--dracula-pink)",
|
||||
weight: "var(--dracula-green)",
|
||||
bloodPressure: "var(--dracula-orange)",
|
||||
};
|
||||
|
||||
const STRENGTH_KEY: Record<CorrelationResult["strength"], string> = {
|
||||
@@ -136,16 +142,22 @@ export function MoodCorrelationCards({
|
||||
sleep,
|
||||
steps,
|
||||
pulse,
|
||||
weight,
|
||||
bloodPressureSystolic,
|
||||
}: {
|
||||
sleep: MoodMetricCorrelationData;
|
||||
steps: MoodMetricCorrelationData;
|
||||
pulse: MoodMetricCorrelationData;
|
||||
weight: MoodMetricCorrelationData;
|
||||
bloodPressureSystolic: MoodMetricCorrelationData;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<MoodCorrelationCard kind="sleep" data={sleep} />
|
||||
<MoodCorrelationCard kind="steps" data={steps} />
|
||||
<MoodCorrelationCard kind="pulse" data={pulse} />
|
||||
<MoodCorrelationCard kind="weight" data={weight} />
|
||||
<MoodCorrelationCard kind="bloodPressure" data={bloodPressureSystolic} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,14 @@ import {
|
||||
type MoodNarrativeItem,
|
||||
} from "./mood-narrative-feed";
|
||||
import { MoodInTargetTile } from "./mood-in-target-tile";
|
||||
import {
|
||||
MoodTimeOfDayChart,
|
||||
type MoodTimeOfDayPattern,
|
||||
} from "./mood-time-of-day-chart";
|
||||
import {
|
||||
MoodStabilityTile,
|
||||
type MoodStabilityData,
|
||||
} from "./mood-stability-tile";
|
||||
|
||||
/**
|
||||
* v1.8.5 — additional Mood Insights sections.
|
||||
@@ -55,6 +63,8 @@ interface MoodInsightsResponse {
|
||||
};
|
||||
distribution: MoodDistributionRow[];
|
||||
weekday: MoodWeekdayRow[];
|
||||
timeOfDay: MoodTimeOfDayPattern;
|
||||
stability: MoodStabilityData | null;
|
||||
tags: MoodTagRow[];
|
||||
structuredTags: MoodStructuredTagRow[];
|
||||
narratives: MoodNarrativeItem[];
|
||||
@@ -62,6 +72,8 @@ interface MoodInsightsResponse {
|
||||
sleep: MoodMetricCorrelationData;
|
||||
steps: MoodMetricCorrelationData;
|
||||
pulse: MoodMetricCorrelationData;
|
||||
weight: MoodMetricCorrelationData;
|
||||
bloodPressureSystolic: MoodMetricCorrelationData;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,9 +134,17 @@ export function MoodInsightsSections() {
|
||||
? data.narratives.filter((item) => item.kind !== "in-target")
|
||||
: data.narratives;
|
||||
|
||||
const hasStabilityTile = data.stability != null;
|
||||
const hasInTargetTile = data.summary.inTargetPct != null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MoodInTargetTile pct={data.summary.inTargetPct} />
|
||||
{(hasInTargetTile || hasStabilityTile) && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<MoodInTargetTile pct={data.summary.inTargetPct} />
|
||||
<MoodStabilityTile stability={data.stability} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MoodNarrativeFeed items={narratives} />
|
||||
|
||||
@@ -145,6 +165,12 @@ export function MoodInsightsSections() {
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
{data.timeOfDay.reliable && (
|
||||
<SectionCard title={t("insights.mood.timeOfDay.title")}>
|
||||
<MoodTimeOfDayChart pattern={data.timeOfDay} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{hasStructuredTags && (
|
||||
<SectionCard title={t("insights.mood.structuredTagsTitle")}>
|
||||
<MoodStructuredTagBreakdown tags={data.structuredTags} />
|
||||
@@ -168,6 +194,8 @@ export function MoodInsightsSections() {
|
||||
sleep={data.correlations.sleep}
|
||||
steps={data.correlations.steps}
|
||||
pulse={data.correlations.pulse}
|
||||
weight={data.correlations.weight}
|
||||
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Flame,
|
||||
Tag,
|
||||
Target,
|
||||
@@ -42,6 +43,7 @@ const ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
||||
"tag-drop": ArrowDownRight,
|
||||
"in-target": Target,
|
||||
streak: Flame,
|
||||
"time-of-day": Clock,
|
||||
};
|
||||
|
||||
function pickIcon(item: MoodNarrativeItem): ComponentType<{
|
||||
@@ -76,6 +78,7 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
|
||||
const vars: Record<string, string> = { ...item.vars };
|
||||
if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey);
|
||||
if (vars.tagKey) vars.tag = t(vars.tagKey);
|
||||
if (vars.bucketKey) vars.bucket = t(vars.bucketKey);
|
||||
return (
|
||||
<li
|
||||
key={`${item.kind}-${index}`}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.9.0 — mood-stability tile.
|
||||
*
|
||||
* Surfaces the day-to-day stability score (0..100, higher = steadier)
|
||||
* computed server-side from the population standard deviation of the
|
||||
* daily means, plus a descriptive, non-judgemental band. Some variation
|
||||
* is healthy, so the band reads "steady" / "variable", never
|
||||
* "good" / "bad". Renders nothing when the score is unavailable (a sparse
|
||||
* logger below the minimum-days floor) so the page degrades gracefully.
|
||||
*/
|
||||
|
||||
export type MoodStabilityBand =
|
||||
| "verySteady"
|
||||
| "steady"
|
||||
| "variable"
|
||||
| "veryVariable";
|
||||
|
||||
export interface MoodStabilityData {
|
||||
score: number;
|
||||
band: MoodStabilityBand;
|
||||
days: number;
|
||||
}
|
||||
|
||||
const BAND_KEY: Record<MoodStabilityBand, string> = {
|
||||
verySteady: "insights.mood.stability.bandVerySteady",
|
||||
steady: "insights.mood.stability.bandSteady",
|
||||
variable: "insights.mood.stability.bandVariable",
|
||||
veryVariable: "insights.mood.stability.bandVeryVariable",
|
||||
};
|
||||
|
||||
export function MoodStabilityTile({
|
||||
stability,
|
||||
}: {
|
||||
stability: MoodStabilityData | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (stability == null) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-full">
|
||||
<Activity className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground text-2xl font-semibold tabular-nums">
|
||||
{t("insights.mood.stability.value", {
|
||||
score: String(stability.score),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(BAND_KEY[stability.band])}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.9.0 — average mood per part of day.
|
||||
*
|
||||
* Four bars (morning / afternoon / evening / night) over the average
|
||||
* mood logged in each part of the user's local day. The buckets are
|
||||
* computed server-side in each entry's own timezone and gated on a
|
||||
* spread floor (`reliable`); the parent only mounts this chart when the
|
||||
* pattern is trustworthy, so a once-a-day logger never sees a misleading
|
||||
* single bar dressed up as a daypart preference. Empty buckets render as
|
||||
* a muted zero-height bar so the four-column rhythm stays intact.
|
||||
*/
|
||||
|
||||
export type MoodTimeOfDayBucket =
|
||||
| "morning"
|
||||
| "afternoon"
|
||||
| "evening"
|
||||
| "night";
|
||||
|
||||
export interface MoodTimeOfDayRow {
|
||||
bucket: MoodTimeOfDayBucket;
|
||||
avgScore: number | null;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MoodTimeOfDayPattern {
|
||||
buckets: MoodTimeOfDayRow[];
|
||||
reliable: boolean;
|
||||
best: MoodTimeOfDayBucket | null;
|
||||
worst: MoodTimeOfDayBucket | null;
|
||||
}
|
||||
|
||||
const LABEL_KEYS: Record<MoodTimeOfDayBucket, string> = {
|
||||
morning: "insights.mood.timeOfDay.morning",
|
||||
afternoon: "insights.mood.timeOfDay.afternoon",
|
||||
evening: "insights.mood.timeOfDay.evening",
|
||||
night: "insights.mood.timeOfDay.night",
|
||||
};
|
||||
|
||||
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 MoodTimeOfDayChart({
|
||||
pattern,
|
||||
}: {
|
||||
pattern: MoodTimeOfDayPattern;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const data = pattern.buckets.map((row) => ({
|
||||
bucket: row.bucket,
|
||||
label: t(LABEL_KEYS[row.bucket]),
|
||||
value: row.avgScore ?? 0,
|
||||
avgScore: row.avgScore,
|
||||
count: row.count,
|
||||
}));
|
||||
|
||||
const bestLabel = pattern.best ? t(LABEL_KEYS[pattern.best]) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="aspect-[3/2] min-h-[160px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 8, right: 12, bottom: 8, left: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
|
||||
stroke="var(--dracula-comment)"
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[1, 5]}
|
||||
ticks={[1, 2, 3, 4, 5]}
|
||||
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
|
||||
stroke="var(--dracula-comment)"
|
||||
width={24}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={3.5}
|
||||
stroke="var(--dracula-green)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--secondary)", opacity: 0.4 }}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
itemStyle={{ color: "var(--dracula-fg)" }}
|
||||
labelStyle={{ color: "var(--dracula-fg)" }}
|
||||
formatter={(_value, _name, item) => {
|
||||
const payload = item?.payload as
|
||||
| { avgScore: number | null; count: number }
|
||||
| undefined;
|
||||
if (!payload || payload.avgScore == null) {
|
||||
return [t("insights.mood.weekdayNoData"), ""];
|
||||
}
|
||||
return [
|
||||
`${payload.avgScore.toFixed(1)} (${payload.count})`,
|
||||
t("insights.mood.timeOfDay.title"),
|
||||
];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
|
||||
{data.map((row) => (
|
||||
<Cell
|
||||
key={row.bucket}
|
||||
fill={
|
||||
row.avgScore == null
|
||||
? "var(--secondary)"
|
||||
: colorForScore(row.avgScore)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{bestLabel && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.mood.timeOfDay.bestCaption", { bucket: bestLabel })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user