mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(mood): capture the five values without lengthening the quick check-in
- the five faces are untouched: pick one, save, done in a tap - a closed section below them opens onto five sliders; picking a face suggests a pleasantness value and moving that slider takes the suggestion over, while the face selection stays where it is - an untouched slider writes nothing, and clearing one takes the answer back rather than leaving a middle value behind - the list's edit dialog shows and changes the same five, so nothing is written on one screen and invisible on the other - correcting an entry no longer re-derives pleasantness when the label did not move, which used to overwrite a hand-set value on any edit - factor ratings are no longer offered on a new entry and nothing was deleted: existing ratings still render, edit and feed the crosstab - six locales, and the dimension table's keys are declared dynamic
This commit is contained in:
1 parent
7eaede257e
commit
97582dedc0
13 files changed
+530
-67
No files matched your search
@@ -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,38 @@ 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\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("sends ratedFactors as a parallel array on POST (omitted when empty)", () => {
|
||||
expect(formSrc).toContain(
|
||||
"ratedFactors: ratedFactors.length > 0 ? ratedFactors : undefined,",
|
||||
);
|
||||
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("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\)/,
|
||||
);
|
||||
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)");
|
||||
});
|
||||
});
|
||||
|
||||
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 +166,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"');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"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")}
|
||||
onClear={() => {
|
||||
if (dimension.key === "a1") onA1Touched?.();
|
||||
onChange({ ...value, [dimension.key]: null });
|
||||
}}
|
||||
onValueChange={(next) => {
|
||||
if (dimension.key === "a1") onA1Touched?.();
|
||||
onChange({ ...value, [dimension.key]: next });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user