Merge pull request #735 from MBombeck/feat/mood-level-a

Five dimensions behind the mood check-in
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-08 04:29:27 +02:00
commit 8045540b7e
47 files changed
+3667 -74

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ export { HostMetricsChart } from "@/components/admin/host-metrics-chart";
export { CustomMetricChart } from "@/components/custom-metrics/custom-metric-chart";
export { BbtChart } from "@/components/cycle/bbt-chart";
export { DeltaSparkline } from "@/components/insights/derived/delta-sparkline";
export { MoodDimensionTrends } from "@/components/insights/mood/mood-dimension-trends";
export { MoodDistributionChart } from "@/components/insights/mood/mood-distribution-chart";
export { MoodTimeOfDayChart } from "@/components/insights/mood/mood-time-of-day-chart";
export { MoodWeekdayChart } from "@/components/insights/mood/mood-weekday-chart";
@@ -0,0 +1,232 @@
"use client";
import { useMemo, useState } from "react";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import {
MOOD_DIMENSIONS,
MOOD_DIMENSION_WINDOWS,
type MoodDimensionWindow,
} from "@/lib/mood/dimensions";
import { cn } from "@/lib/utils";
/**
* v1.37 — the five level-A dimensions over time.
*
* Each dimension is drawn in the orientation the person answered it in and
* under the label they read, so a high stress line means a stressful stretch
* rather than a good one. Nothing is flipped for the picture's convenience;
* the legend says which way each scale runs.
*
* A dimension nobody has answered is not drawn and is named underneath as not
* recorded. Drawing it flat through the middle would put five answers on the
* chart for a question that was asked once.
*/
export interface MoodDimensionPointData {
date: string;
value: number;
samples: number;
}
export interface MoodDimensionSummaryData {
key: string;
present: boolean;
inverse: boolean;
min: number;
max: number;
count: number;
avg7: number | null;
avg30: number | null;
avg90: number | null;
latest: number | null;
latestDate: string | null;
newestDaysAgo: number | null;
series: MoodDimensionPointData[];
}
/** One colour per dimension, from the same semantic set the other charts use. */
const COLOR_BY_KEY: Record<string, string> = {
a1: "var(--chart-1)",
a2: "var(--destructive)",
a3: "var(--dracula-orange)",
a4: "var(--info)",
a5: "var(--success)",
};
function meanForWindow(
summary: MoodDimensionSummaryData,
window: MoodDimensionWindow,
): number | null {
if (window === 7) return summary.avg7;
if (window === 30) return summary.avg30;
return summary.avg90;
}
export function MoodDimensionTrends({
dimensions,
}: {
dimensions: MoodDimensionSummaryData[];
}) {
const { t } = useTranslations();
const [window, setWindow] = useState<MoodDimensionWindow>(30);
const present = dimensions.filter((d) => d.present);
const missing = dimensions.filter((d) => !d.present);
const chartData = useMemo(() => {
const byDate = new Map<string, Record<string, number | string>>();
for (const summary of present) {
for (const point of summary.series) {
const row = byDate.get(point.date) ?? { date: point.date };
row[summary.key] = point.value;
byDate.set(point.date, row);
}
}
const rows = [...byDate.values()].sort((a, b) =>
String(a.date).localeCompare(String(b.date)),
);
// The series carry the longest window; the shorter ones are a slice of the
// same points rather than a second read.
return rows.slice(Math.max(0, rows.length - window));
}, [present, window]);
const labelFor = (key: string) => {
const dimension = MOOD_DIMENSIONS.find((d) => d.key === key);
return dimension ? t(dimension.labelKey) : key;
};
if (present.length === 0) {
return (
<p className="text-muted-foreground text-sm">
{t("insights.mood.dimensions.none")}
</p>
);
}
return (
<div className="space-y-3" data-slot="mood-dimension-trends">
<div className="flex flex-wrap items-center gap-1.5">
{MOOD_DIMENSION_WINDOWS.map((option) => (
<button
key={option}
type="button"
onClick={() => setWindow(option)}
aria-pressed={window === option}
className={cn(
"min-h-9 rounded-full border px-3 text-xs transition-colors",
window === option
? "border-primary bg-primary/15 text-primary"
: "border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{t("insights.mood.dimensions.window", { days: option })}
</button>
))}
</div>
<div className="h-[clamp(160px,34vh,220px)] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 4, right: 8, bottom: 0, left: -20 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickFormatter={(value: string) => value.slice(5)}
minTickGap={24}
/>
<YAxis
domain={[0, 10]}
ticks={[0, 5, 10]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
/>
<Tooltip
contentStyle={{
background: "var(--popover)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
labelStyle={{ color: "var(--muted-foreground)" }}
formatter={(value, name) => [
String(value ?? ""),
labelFor(String(name)),
]}
/>
{present.map((summary) => (
<Line
key={summary.key}
type="monotone"
dataKey={summary.key}
name={summary.key}
stroke={COLOR_BY_KEY[summary.key] ?? "var(--chart-1)"}
strokeWidth={2}
dot={false}
// A gap is a day nobody answered; joining across it would draw
// a value that was never given.
connectNulls={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<ul className="space-y-1.5">
{present.map((summary) => {
const mean = meanForWindow(summary, window);
const dimension = MOOD_DIMENSIONS.find((d) => d.key === summary.key);
return (
<li
key={summary.key}
className="flex items-baseline justify-between gap-3 text-sm"
>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className="inline-block size-2 shrink-0 rounded-full"
style={{
background: COLOR_BY_KEY[summary.key] ?? "var(--chart-1)",
}}
/>
<span>{labelFor(summary.key)}</span>
{summary.inverse && dimension ? (
<span className="text-muted-foreground text-xs">
{t("insights.mood.dimensions.higherIsMore", {
anchor: t(dimension.highAnchorKey),
})}
</span>
) : null}
</span>
<span className="text-foreground shrink-0 tabular-nums">
{mean === null
? t("insights.mood.dimensions.noneInWindow")
: mean}
</span>
</li>
);
})}
</ul>
{missing.length > 0 ? (
<p className="text-muted-foreground text-xs">
{t("insights.mood.dimensions.notRecorded", {
list: missing.map((d) => labelFor(d.key)).join(", "),
})}
</p>
) : null}
</div>
);
}
@@ -34,12 +34,25 @@ import { MoodHeatmap } from "@/components/charts/mood-heatmap";
// without a layout shift (charts stay Recharts, visually identical). Types
// stay value-free imports so they don't drag the chunk back in.
import type { MoodDistributionRow } from "./mood-distribution-chart";
import type { MoodDimensionSummaryData } from "./mood-dimension-trends";
import type { MoodWeekdayRow } from "./mood-weekday-chart";
import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart";
// v1.16.7 established the shared-barrel pattern for the trio; all three
// loaders now resolve the app-wide chart-runtime boundary, so they share
// the ONE recharts chunk with every other chart surface: the cards reveal
// together, and recharts ships exactly once for the whole app.
const MoodDimensionTrends = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodDimensionTrends,
})),
{
ssr: false,
loading: () => (
<Skeleton className="h-[clamp(160px,34vh,220px)] w-full rounded-md" />
),
},
);
const MoodDistributionChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
@@ -127,6 +140,9 @@ interface MoodInsightsResponse {
cells: Array<{ date: string; score: number; samples: number }>;
};
distribution: MoodDistributionRow[];
// Optional only to tolerate a stale pre-v1.37 cached payload during a
// rollout; the live endpoint always populates it.
dimensions?: MoodDimensionSummaryData[];
weekday: MoodWeekdayRow[];
timeOfDay: MoodTimeOfDayPattern;
stability: MoodStabilityData | null;
@@ -258,6 +274,8 @@ export function MoodInsightsSections({
const hasCrosstab = crosstabRows.length > 0;
const factorCrosstabRows = data.factorCrosstab ?? [];
const hasFactorCrosstab = factorCrosstabRows.length > 0;
const dimensionSummaries = data.dimensions ?? [];
const hasDimensions = dimensionSummaries.some((d) => d.present);
// v1.12.7 — the Stimmungskalender is lifted above the line chart on the page,
// so it renders as its own region here.
@@ -334,6 +352,15 @@ export function MoodInsightsSections({
renders nothing when both halves are empty. */}
<MoodWhatStandsOut narratives={narratives} />
{/* v1.37 — the five level-A dimensions. Renders only once at least one
of them carries a value; an account that has never opened the detail
section sees the page it saw before. */}
{hasDimensions && (
<SectionCard title={t("insights.mood.dimensions.title")} icon={Gauge}>
<MoodDimensionTrends dimensions={dimensionSummaries} />
</SectionCard>
)}
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard
title={t("insights.mood.distributionTitle")}
@@ -85,7 +85,10 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
expect(formSrc).toMatch(/const isDirty =\s*\n?\s*mood !== ""/);
expect(formSrc).toContain('tagsInput.trim() !== ""');
expect(formSrc).toContain("tagKeys.length > 0");
expect(formSrc).toContain("ratedFactors.length > 0");
// v1.37 — a level-A value the user set is typed input too. The rated
// factors this line used to name are no longer captured here; see the
// level-A describe below.
expect(formSrc).toContain("Object.values(levelA).some((v) => v !== null)");
expect(formSrc).toContain('note.trim() !== ""');
// The `isDirty` expression itself must not read `moodLoggedAt`.
const isDirtyExpr = formSrc.slice(
@@ -115,31 +118,51 @@ describe("MoodForm — Reset confirm when dirty (v1.11.5)", () => {
});
});
describe("MoodForm — rated factors (v1.12.0)", () => {
it("lifts a ratedFactors set and threads it to the tag picker", () => {
expect(formSrc).toContain("const [ratedFactors, setRatedFactors]");
expect(formSrc).toMatch(
/function rateFactor\(key: string, rating: number \| null\)/,
);
// The picker receives both the binary toggle and the factor rate
// callbacks so it can render BINARY chips and RATED controls.
expect(formSrc).toMatch(
/<MoodTagPicker[\s\S]*ratedFactors=\{ratedFactors\}[\s\S]*onRateFactor=\{rateFactor\}/,
describe("MoodForm — level A replaces the factor ratings (v1.37)", () => {
it("no longer offers a factor rating on a new entry", () => {
// A 1-5 score for "work stress" and a 0-10 stress self-state are two
// answers to one question. The create sheet asks the second one only.
// Nothing was deleted: the catalogue rows, the stored ratings and the
// edit dialog below all still work, which the next describe pins.
expect(formSrc).not.toContain("onRateFactor=");
expect(formSrc).not.toContain('mode="rated"');
expect(formSrc).not.toContain("ratedFactors");
// The binary structured-tag contract stays byte-identical.
expect(formSrc).toContain('mode="binary"');
expect(formSrc).toContain(
"tagKeys: tagKeys.length > 0 ? tagKeys : undefined,",
);
});
it("sends ratedFactors as a parallel array on POST (omitted when empty)", () => {
expect(formSrc).toContain(
"ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,",
it("offers the five level-A sliders and sends only the answered ones", () => {
expect(formSrc).toContain("<MoodLevelASection");
expect(formSrc).toContain("...levelACreatePayload(levelA)");
// Picking a face seeds pleasantness; moving that slider ends the seeding.
expect(formSrc).toMatch(
/function pickMood\(value: string\) \{[\s\S]*seedA1FromMood\(prev, value, a1Touched\)/,
);
// The binary structured-tag contract stays byte-identical.
expect(formSrc).toContain(
"tagKeys: tagKeys.length > 0 ? tagKeys : undefined,",
expect(formSrc).toContain("onA1Touched={() => setA1Touched(true)}");
// Nothing here defaults a value: the state starts empty and stays empty.
expect(formSrc).toContain("useState<LevelAState>(EMPTY_LEVEL_A)");
});
it("offers no clear control for pleasantness, matching the write path", () => {
// An entry always carries a label, so it always implies a pleasantness
// value and both write routes derive one from a null. A clear button here
// would be a control the server undoes on the way to the database.
const fieldsSrc = readFileSync(
resolve(__dirname, "../mood-level-a-fields.tsx"),
"utf8",
);
expect(fieldsSrc).toMatch(
/onClear=\{\s*dimension\.key === "a1"\s*\?\s*undefined/,
);
});
});
describe("MoodList — rated factor editing", () => {
// Unchanged by v1.37 on purpose: the create sheet stopped asking, and an
// entry that already carries a rating must still be correctable.
it("hydrates, renders, and submits the entry's current ratedFactors", () => {
expect(listSrc).toContain("ratedFactors: RatedFactor[];");
expect(listSrc).toContain(
@@ -156,6 +179,18 @@ describe("MoodList — rated factor editing", () => {
});
});
describe("MoodList — level A on the edit path (v1.37)", () => {
it("seeds the five values from the entry and sends them back, nulls included", () => {
// What the capture sheet writes, this dialog has to show. A value visible
// in a trend and nowhere else is a value the person cannot correct.
expect(listSrc).toContain("levelA: levelAFromEntry(entry)");
expect(listSrc).toContain("setEditLevelA(seed.levelA)");
expect(listSrc).toContain("<MoodLevelASection");
// Nulls travel: this path replaces, so a cleared value has to be stated.
expect(listSrc).toContain("...levelAUpdatePayload(levelA)");
});
});
describe("MoodList — delete failure toast (v1.11.5)", () => {
it("surfaces a toast on a rejected delete instead of failing silently", () => {
expect(listSrc).toContain('import { toast } from "sonner"');
+47 -35
View File
@@ -31,7 +31,6 @@ import {
MoreHorizontal,
Plus,
RotateCcw,
SlidersHorizontal,
StickyNote,
} from "lucide-react";
import { toast } from "sonner";
@@ -45,7 +44,14 @@ import {
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
import { MoodTagPicker } from "./mood-tag-picker";
import {
EMPTY_LEVEL_A,
MoodLevelASection,
levelACreatePayload,
seedA1FromMood,
type LevelAState,
} from "./mood-level-a-fields";
import { MoodQuickTags } from "./mood-quick-tags";
import { useRecentTags } from "./recent-tags";
import { moodFaceIcon } from "./mood-tag-icons";
@@ -114,14 +120,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({
count: MOOD_LEVELS.length,
selectedIndex: MOOD_LEVELS.findIndex((l) => l.value === mood),
onSelect: (index) => setMood(MOOD_LEVELS[index]!.value),
onSelect: (index) => pickMood(MOOD_LEVELS[index]!.value),
});
// v1.37 — the five level-A values. Pleasantness follows the picked face
// until the user moves it themselves; the other four start and stay empty
// unless somebody answers them.
const [levelA, setLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
const [a1Touched, setA1Touched] = useState(false);
const [levelAOpen, setLevelAOpen] = useState(false);
const [tagsInput, setTagsInput] = useState("");
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
const [tagKeys, setTagKeys] = useState<string[]>([]);
// v1.12.0 — rated mood factors (FACTOR-kind catalog tags scored on
// their own 1..scaleMax scale). Sent as `ratedFactors: [{key,rating}]`.
const [ratedFactors, setRatedFactors] = useState<RatedFactor[]>([]);
// 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.
@@ -142,24 +151,25 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
mood !== "" ||
tagsInput.trim() !== "" ||
tagKeys.length > 0 ||
ratedFactors.length > 0 ||
// A level-A value the user set counts as typed input: Reset must ask
// before it discards an answer that took five slider moves.
Object.values(levelA).some((v) => v !== null) ||
note.trim() !== "";
// v1.37 — picking a face suggests a pleasantness value; the suggestion
// stands until the user moves that slider, after which the two are
// independent. Moving the slider never changes which face is lit.
function pickMood(value: string) {
setMood(value);
setLevelA((prev) => seedA1FromMood(prev, value, a1Touched));
}
function toggleTagKey(key: string) {
setTagKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
}
// v1.12.0 — set or clear a rated factor's score. `rating === null`
// removes the factor from the set (re-tapping the active step clears).
function rateFactor(key: string, rating: number | null) {
setRatedFactors((prev) => {
const rest = prev.filter((f) => f.key !== key);
return rating === null ? rest : [...rest, { key, rating }];
});
}
// v1.4.27 MB3 — error banner descriptor for the timestamp input.
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
@@ -171,9 +181,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
function resetForm() {
setMood("");
setLevelA(EMPTY_LEVEL_A);
setA1Touched(false);
setLevelAOpen(false);
setTagsInput("");
setTagKeys([]);
setRatedFactors([]);
setNote("");
setMoodLoggedAt(getDefaultMoodLoggedAtValue());
setError(null);
@@ -209,9 +221,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
mood,
tags: tags.length > 0 ? tags : undefined,
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
// v1.12.0 — rated factors as a parallel [{key,rating}] array.
ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,
note: trimmedNote.length > 0 ? trimmedNote : undefined,
// v1.37 — only the values somebody answered. An untouched slider
// sends nothing, and the server writes NULL rather than a middle.
...levelACreatePayload(levelA),
moodLoggedAt: timestamp,
});
@@ -324,7 +337,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
aria-label={t(level.labelKey)}
data-slot="mood-face"
data-mood={level.value}
onClick={() => setMood(level.value)}
onClick={() => pickMood(level.value)}
{...getMoodRadioProps(index)}
// v1.12 — selected-state unified with the tag tiles and
// factor steps: `border-primary bg-primary/15 text-primary`
@@ -392,21 +405,20 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
/>
</SheetSection>
{/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept
exactly as the existing segmented control. */}
<SheetSection
title={t("mood.sectionFactors")}
icon={<SlidersHorizontal />}
summary={<SheetSectionCount count={ratedFactors.length} />}
>
<MoodTagPicker
selected={tagKeys}
onToggle={toggleTagKey}
ratedFactors={ratedFactors}
onRateFactor={rateFactor}
mode="rated"
/>
</SheetSection>
{/* v1.37 — the five level-A values, closed by default. The rated
factors that used to sit here are no longer offered on a new
entry: a 1-5 score for "work stress" and a 0-10 stress
self-state are different questions, and asking both invites
two answers to one thing. Existing ratings keep working
everywhere they already do, including the edit dialog, and no
catalogue row was removed. */}
<MoodLevelASection
value={levelA}
onChange={setLevelA}
onA1Touched={() => setA1Touched(true)}
open={levelAOpen}
onOpenChange={setLevelAOpen}
/>
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
and the note field. The badge counts whichever of these carry
+194
View File
@@ -0,0 +1,194 @@
"use client";
/**
* The five level-A sliders, and the state rules around them.
*
* One component for both mood surfaces — the create sheet and the list's edit
* dialog — because what one writes the other has to be able to show. A value a
* user set on Monday and cannot see on Tuesday is worse than one they were
* never offered.
*
* The section is closed by default and nothing in it is required. The five
* faces above it remain a complete entry on their own: one tap still saves.
*/
import { Gauge } from "lucide-react";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { SliderField } from "@/components/ui/slider";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import { getA1ForMood } from "@/lib/validations/mood";
import { useTranslations } from "@/lib/i18n/context";
/** The five values as the form holds them. `null` means unanswered. */
export interface LevelAState {
a1: number | null;
a2: number | null;
a3: number | null;
a4: number | null;
a5: number | null;
}
export const EMPTY_LEVEL_A: LevelAState = {
a1: null,
a2: null,
a3: null,
a4: null,
a5: null,
};
/** How many of the five carry an answer — the collapsed section's badge. */
export function levelAAnsweredCount(state: LevelAState): number {
return MOOD_DIMENSIONS.filter((d) => state[d.key] !== null).length;
}
/**
* The five values as the API takes them, with the unanswered ones left out.
*
* Omitted rather than sent as null on the create path: absent means "nothing
* to say", and the server writes NULL for it. Nothing here ever sends a
* default, which is the whole reason the columns are nullable.
*/
export function levelACreatePayload(
state: LevelAState,
): Partial<Record<keyof LevelAState, number>> {
const out: Partial<Record<keyof LevelAState, number>> = {};
for (const dimension of MOOD_DIMENSIONS) {
const value = state[dimension.key];
if (value !== null) out[dimension.key] = value;
}
return out;
}
/**
* The five values as the edit path takes them — nulls included.
*
* The edit path replaces rather than adds, so a value the user cleared has to
* travel as an explicit null. Sending nothing would leave the old answer in
* place and the dialog would lie about what it just saved.
*/
export function levelAUpdatePayload(state: LevelAState): LevelAState {
return { ...state };
}
/**
* Seed the state from an entry being edited.
*
* The reader is the API's own wire shape, so a row that has never carried
* these values arrives as `undefined` and lands as `null` — unanswered, which
* is what it is.
*/
export function levelAFromEntry(entry: {
a1?: number | null;
a2?: number | null;
a3?: number | null;
a4?: number | null;
a5?: number | null;
}): LevelAState {
return {
a1: entry.a1 ?? null,
a2: entry.a2 ?? null,
a3: entry.a3 ?? null,
a4: entry.a4 ?? null,
a5: entry.a5 ?? null,
};
}
/**
* Re-seed pleasantness from a freshly picked face — but only while the user
* has not moved that slider themselves.
*
* This is the one interaction in the sheet that will confuse a later reader,
* so it is written down here rather than inferred from two call sites: picking
* a face suggests a pleasantness value, and moving the slider takes the
* suggestion over. After that the faces and the slider are independent, and in
* particular moving the slider never changes which face is lit — the two are
* different questions at different resolutions, not two views of one answer.
*/
export function seedA1FromMood(
state: LevelAState,
mood: string,
a1Touched: boolean,
): LevelAState {
if (a1Touched) return state;
return { ...state, a1: mood ? getA1ForMood(mood) : null };
}
interface MoodLevelASectionProps {
value: LevelAState;
onChange: (next: LevelAState) => void;
/** Called when the user moves pleasantness themselves, which ends the re-seeding. */
onA1Touched?: () => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function MoodLevelASection({
value,
onChange,
onA1Touched,
open,
onOpenChange,
}: MoodLevelASectionProps) {
const { t } = useTranslations();
return (
<SheetSection
title={t("mood.sectionLevelA")}
icon={<Gauge />}
open={open}
onOpenChange={onOpenChange}
summary={<SheetSectionCount count={levelAAnsweredCount(value)} />}
>
<div className="space-y-4" data-slot="mood-level-a">
<p className="text-muted-foreground text-xs">
{t("mood.sectionLevelAHelp")}
</p>
{MOOD_DIMENSIONS.map((dimension) => {
const current = value[dimension.key];
const low = t(dimension.lowAnchorKey);
const high = t(dimension.highAnchorKey);
return (
<SliderField
key={dimension.key}
label={t(dimension.labelKey)}
value={current}
min={dimension.min}
max={dimension.max}
step={1}
lowAnchor={low}
highAnchor={high}
emptyLabel={t("mood.dimensionNotAnswered")}
valueText={
current === null
? undefined
: t("mood.dimensionValueText", {
value: current,
max: dimension.max,
low,
high,
})
}
clearLabel={t("mood.dimensionClear")}
// Pleasantness has no clear control, and that is the same rule
// the server keeps: an entry always carries a five-point label,
// so it always implies a pleasantness value, and there is no
// state in which the field is honestly unanswered. Offering a
// control that the write path would immediately undo would be a
// button that quietly does nothing. The other four are genuinely
// optional and keep theirs.
onClear={
dimension.key === "a1"
? undefined
: () => onChange({ ...value, [dimension.key]: null })
}
onValueChange={(next) => {
if (dimension.key === "a1") onA1Touched?.();
onChange({ ...value, [dimension.key]: next });
}}
/>
);
})}
</div>
</SheetSection>
);
}
+36
View File
@@ -87,6 +87,13 @@ import {
} from "@/components/data-list";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
import {
EMPTY_LEVEL_A,
MoodLevelASection,
levelAFromEntry,
levelAUpdatePayload,
type LevelAState,
} from "./mood-level-a-fields";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
@@ -104,6 +111,12 @@ interface MoodEntry {
// v1.8.5 — structured-tag keys + free-text note.
tagKeys: string[];
ratedFactors: RatedFactor[];
// v1.37 — the five level-A values, absent on entries written before them.
a1?: number | null;
a2?: number | null;
a3?: number | null;
a4?: number | null;
a5?: number | null;
note: string | null;
source: string;
moodLoggedAt: string;
@@ -185,6 +198,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const [editTagsInput, setEditTagsInput] = useState("");
const [editTagKeys, setEditTagKeys] = useState<string[]>([]);
const [editRatedFactors, setEditRatedFactors] = useState<RatedFactor[]>([]);
// v1.37 — the five level-A values on the edit path. Seeded from the entry,
// so what the capture sheet wrote is visible and changeable here.
const [editLevelA, setEditLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
const [editNote, setEditNote] = useState("");
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
const [editSeed, setEditSeed] = useState<{
@@ -192,6 +208,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagsInput: string;
tagKeys: string[];
ratedFactors: RatedFactor[];
levelA: LevelAState;
note: string;
moodLoggedAt: string;
}>({
@@ -199,6 +216,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tagsInput: "",
tagKeys: [],
ratedFactors: [],
levelA: EMPTY_LEVEL_A,
note: "",
moodLoggedAt: "",
});
@@ -391,6 +409,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tags,
tagKeys,
ratedFactors,
levelA,
note,
moodLoggedAt,
}: {
@@ -399,6 +418,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tags: string[] | null;
tagKeys: string[];
ratedFactors: RatedFactor[];
levelA: LevelAState;
note: string | null;
moodLoggedAt: string;
}) => {
@@ -407,6 +427,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tags,
tagKeys,
ratedFactors,
// Nulls included: this path replaces rather than adds, so a value the
// user cleared has to travel as an explicit null or the old answer
// survives an edit that visibly removed it.
...levelAUpdatePayload(levelA),
note,
moodLoggedAt,
});
@@ -421,6 +445,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
ratedFactors: [...editRatedFactors].sort((a, b) =>
a.key.localeCompare(b.key),
),
levelA: editLevelA,
note: editNote,
moodLoggedAt: editMoodLoggedAt,
});
@@ -439,6 +464,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
ratedFactors: [...editRatedFactors].sort((a, b) =>
a.key.localeCompare(b.key),
),
levelA: editLevelA,
note: editNote,
moodLoggedAt: editMoodLoggedAt,
},
@@ -455,6 +481,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
ratedFactors: [...(entry.ratedFactors ?? [])].sort((a, b) =>
a.key.localeCompare(b.key),
),
levelA: levelAFromEntry(entry),
note: entry.note ?? "",
moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt),
};
@@ -463,6 +490,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
setEditTagsInput(seed.tagsInput);
setEditTagKeys(seed.tagKeys);
setEditRatedFactors(seed.ratedFactors);
setEditLevelA(seed.levelA);
setEditNote(seed.note);
setEditMoodLoggedAt(seed.moodLoggedAt);
setEditSeed(seed);
@@ -515,6 +543,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
tags: tags.length > 0 ? tags : null,
tagKeys: editTagKeys,
ratedFactors: editRatedFactors,
levelA: editLevelA,
note: trimmedNote.length > 0 ? trimmedNote : null,
moodLoggedAt: measuredDate.toISOString(),
});
@@ -1008,6 +1037,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
/>
</div>
{/* v1.37 — the five level-A values, seeded from the entry.
Pleasantness is not re-seeded from the face here: this dialog
edits an entry that already has an answer, and replacing it
with the derived one would quietly discard what the person
set. */}
<MoodLevelASection value={editLevelA} onChange={setEditLevelA} />
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<Label htmlFor="edit-mood-note">
+120
View File
@@ -0,0 +1,120 @@
/**
* `<Slider>` / `<SliderField>` — the shape and the unanswered state.
*
* The suite renders server-side (`@testing-library/react` is not a dependency
* here), so nothing below drags a thumb or presses an arrow key; the keyboard
* behaviour is Radix's own contract and the manual pass covers it. What is
* proven here is what the component decides: that an unanswered field renders
* no number and announces itself as unanswered rather than as the value its
* parked thumb sits on, that a zero is rendered as an answer, and that the
* anchors reach the markup as muted legend text.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Slider, SliderField } from "../slider";
function field(props: Partial<React.ComponentProps<typeof SliderField>> = {}) {
return renderToStaticMarkup(
<SliderField
label="Stress"
value={7}
onValueChange={() => {}}
emptyLabel="nicht angegeben"
valueText="7 von 10"
lowAnchor="vollkommen entspannt"
highAnchor="extrem belastet"
{...props}
/>,
);
}
describe("<Slider>", () => {
it("renders the Radix parts under their slots", () => {
const html = renderToStaticMarkup(
<Slider value={[4]} min={0} max={10} step={1} />,
);
expect(html).toContain('data-slot="slider"');
expect(html).toContain('data-slot="slider-track"');
expect(html).toContain('data-slot="slider-range"');
expect(html).toContain('data-slot="slider-thumb"');
});
it("announces the caller's text instead of the bare number", () => {
const html = renderToStaticMarkup(
<Slider
value={[6]}
min={0}
max={10}
valueText="6 von 10: sehr unangenehm bis sehr angenehm"
thumbLabel="Stimmung"
/>,
);
expect(html).toContain(
'aria-valuetext="6 von 10: sehr unangenehm bis sehr angenehm"',
);
expect(html).toContain('aria-label="Stimmung"');
// The thumb is a slider to assistive tech, with the bounds published.
// `aria-valuenow` is deliberately not asserted: Radix sets it after mount,
// so it is absent from server markup and a test demanding it here would be
// measuring the renderer rather than the component.
expect(html).toContain('role="slider"');
expect(html).toContain('aria-valuemin="0"');
expect(html).toContain('aria-valuemax="10"');
expect(html).toContain('aria-orientation="horizontal"');
});
});
describe("<SliderField>", () => {
it("renders the label, the value and both anchors", () => {
const html = field();
expect(html).toContain("Stress");
expect(html).toContain("vollkommen entspannt");
expect(html).toContain("extrem belastet");
expect(html).toContain('data-slot="slider-field-value"');
expect(html).toContain(">7<");
});
it("puts the answer in content colour and the anchors in muted", () => {
const html = field();
expect(html).toContain("tabular-nums");
expect(html).toContain("text-foreground");
expect(html).toContain("text-muted-foreground");
expect(html).toContain("text-xs");
});
it("renders an unanswered field as unanswered, not as its parked value", () => {
const html = field({ value: null, valueText: "should not be announced" });
expect(html).toContain('data-unset="true"');
expect(html).toContain("nicht angegeben");
// The thumb sits at the low end so the track reads empty, but nothing
// claims that low end as the person's answer.
expect(html).toContain('aria-valuetext="nicht angegeben"');
expect(html).not.toContain("should not be announced");
});
it("renders a zero as an answer", () => {
// Zero is a real point on this scale. A field that treated it as absence
// would show the empty label and lose an answer somebody gave.
const html = field({ value: 0, valueText: "0 von 10" });
expect(html).not.toContain('data-unset="true"');
expect(html).not.toContain("nicht angegeben");
expect(html).toContain('aria-valuetext="0 von 10"');
});
it("offers to clear an answer only when there is one to clear", () => {
const set = field({ onClear: () => {}, clearLabel: "zurücksetzen" });
expect(set).toContain('data-slot="slider-field-clear"');
expect(set).toContain("zurücksetzen");
const unanswered = field({
value: null,
onClear: () => {},
clearLabel: "zurücksetzen",
});
expect(unanswered).not.toContain('data-slot="slider-field-clear"');
const noHandler = field();
expect(noHandler).not.toContain('data-slot="slider-field-clear"');
});
});
+167
View File
@@ -0,0 +1,167 @@
"use client";
import * as React from "react";
import { Slider as SliderPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* Numeric range entry.
*
* The alternative this replaces is eleven discrete buttons, which is what the
* rated-factor picker does for a 1-5 scale. That pattern does not survive the
* jump to 0-10: eleven 32 px targets with 4 px gaps come to roughly 392 px
* before the label, and the narrow phone this app is mostly used on is 390 px
* wide. A slider answers the same question in a fixed width at any scale, and
* arrow keys move it a step at a time, which eleven buttons never did.
*
* `Slider` is the bare control. `SliderField` is the labelled row around it —
* name on the left, value on the right, the two scale anchors underneath — and
* it is what a capture surface should reach for, because the anchors are what
* make a 7 mean anything.
*/
function Slider({
className,
valueText,
thumbLabel,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root> & {
/**
* What a screen reader announces instead of the bare number. A slider that
* says "6" tells somebody who cannot see the anchors nothing at all; the
* caller passes the number together with what the ends of the scale mean.
*/
valueText?: string;
/** Accessible name for the thumb when the field label is not adjacent. */
thumbLabel?: string;
}) {
return (
<SliderPrimitive.Root
data-slot="slider"
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50",
// The 44 px hit target WCAG 2.5.5 asks for, without a 44 px track:
// the padding is vertical only, so the visible rail stays 6 px.
"py-[19px]",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="bg-muted relative h-1.5 w-full grow overflow-hidden rounded-full"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="bg-primary absolute h-full"
/>
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
data-slot="slider-thumb"
aria-label={thumbLabel}
aria-valuetext={valueText}
className="border-primary bg-background focus-visible:ring-ring/50 block size-4 rounded-full border-2 shadow-sm transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:pointer-events-none"
/>
</SliderPrimitive.Root>
);
}
/**
* A labelled slider with its scale anchors.
*
* `value` is nullable and the null is load-bearing: a question nobody answered
* is not a question answered in the middle. An unset field renders no number
* and fills no track, and only an interaction commits a value. `onClear`, when
* given, renders the control that takes an answer back — without it a user who
* nudged a slider by accident can never return the field to empty.
*/
function SliderField({
label,
value,
onValueChange,
onClear,
clearLabel,
emptyLabel,
valueText,
lowAnchor,
highAnchor,
min = 0,
max = 10,
step = 1,
className,
...props
}: {
label: string;
value: number | null;
onValueChange: (value: number) => void;
onClear?: () => void;
clearLabel?: string;
/** Rendered in place of the number while the field is unanswered. */
emptyLabel: string;
/** Announced by a screen reader; carries the anchors, not only the number. */
valueText?: string;
lowAnchor: string;
highAnchor: string;
min?: number;
max?: number;
step?: number;
className?: string;
} & Omit<
React.ComponentProps<typeof SliderPrimitive.Root>,
"value" | "onValueChange" | "min" | "max" | "step" | "className"
>) {
const unset = value === null;
return (
<div
data-slot="slider-field"
data-unset={unset ? "true" : undefined}
className={cn("flex flex-col gap-1", className)}
>
<div className="flex items-baseline justify-between gap-2">
{/* Content colour for the label and the value, muted for the anchors
below: the anchors are the legend, the value is the answer. */}
<span className="text-sm font-medium">{label}</span>
<span className="flex items-center gap-2">
<span
data-slot="slider-field-value"
className={cn(
"text-sm tabular-nums",
unset ? "text-muted-foreground" : "text-foreground",
)}
>
{unset ? emptyLabel : value}
</span>
{!unset && onClear ? (
<button
type="button"
onClick={onClear}
data-slot="slider-field-clear"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded text-xs underline-offset-2 outline-none hover:underline focus-visible:ring-[3px]"
>
{clearLabel}
</button>
) : null}
</span>
</div>
<Slider
// An unanswered field parks the thumb at the low end with an empty
// track, so it reads as "nothing here" rather than as the value the
// low end happens to carry. The announced text says the same.
value={[unset ? min : value]}
onValueChange={(next) => onValueChange(next[0])}
min={min}
max={max}
step={step}
thumbLabel={label}
valueText={unset ? emptyLabel : valueText}
{...props}
/>
<div className="text-muted-foreground flex justify-between gap-2 text-xs">
<span>{lowAnchor}</span>
<span className="text-right">{highAnchor}</span>
</div>
</div>
);
}
export { Slider, SliderField };