mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(entry): make mood and cycle add a sectioned single sheet
Redesign both add flows as one sheet that is fast by default and deep on demand: the mood face and a quick row of recently used tags stay open, the period buttons and flow stay open for cycle, and everything else moves into collapsible sections that each show a count of what is set inside them. A shared SheetSection primitive carries the collapse, the summary slot and the reduced-motion behaviour. Tag, factor and day-log logic is moved, not rewritten, and the submit path is unchanged.
This commit is contained in:
commit
8b73acfa9c
15 files changed
+1042
-228
No files matched your search
+10
-2
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Wetter",
|
||||
"iconGroupPlaces": "Orte",
|
||||
"iconGroupMisc": "Sonstiges"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "Weitere Tags",
|
||||
"sectionFactors": "Faktoren",
|
||||
"sectionNoteDetails": "Notiz & Details",
|
||||
"quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt",
|
||||
"quickTagsExpand": "Weitere"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medikamente",
|
||||
@@ -5926,7 +5931,10 @@
|
||||
"contraceptive": "Verhütung",
|
||||
"severity": "Schweregrad",
|
||||
"severityLevel": "Schweregrad {level} von 4",
|
||||
"delete": "Löschen"
|
||||
"delete": "Löschen",
|
||||
"temperatureSection": "Temperatur & Eisprung",
|
||||
"intimacySection": "Intimität & Verhütung",
|
||||
"testsSection": "Tests"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Zyklus-Einstellungen",
|
||||
|
||||
+10
-2
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Weather",
|
||||
"iconGroupPlaces": "Places",
|
||||
"iconGroupMisc": "Other"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "More tags",
|
||||
"sectionFactors": "Factors",
|
||||
"sectionNoteDetails": "Note & details",
|
||||
"quickTagsHelp": "Your frequent tags — one tap",
|
||||
"quickTagsExpand": "More"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medications",
|
||||
@@ -5926,7 +5931,10 @@
|
||||
"saved": "Day saved",
|
||||
"saveError": "Could not save. Try again.",
|
||||
"periodStarted": "Period start logged",
|
||||
"clear": "None"
|
||||
"clear": "None",
|
||||
"temperatureSection": "Temperature & ovulation",
|
||||
"intimacySection": "Intimacy & contraception",
|
||||
"testsSection": "Tests"
|
||||
},
|
||||
"testResult": {
|
||||
"POSITIVE": "Positive",
|
||||
|
||||
+10
-2
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Tiempo",
|
||||
"iconGroupPlaces": "Lugares",
|
||||
"iconGroupMisc": "Otros"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "Más etiquetas",
|
||||
"sectionFactors": "Factores",
|
||||
"sectionNoteDetails": "Nota y detalles",
|
||||
"quickTagsHelp": "Tus etiquetas frecuentes: un toque",
|
||||
"quickTagsExpand": "Más"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medicamentos",
|
||||
@@ -5926,7 +5931,10 @@
|
||||
"contraceptive": "Anticonceptivo",
|
||||
"severity": "Intensidad",
|
||||
"severityLevel": "Intensidad {level} de 4",
|
||||
"delete": "Eliminar"
|
||||
"delete": "Eliminar",
|
||||
"temperatureSection": "Temperatura y ovulación",
|
||||
"intimacySection": "Intimidad y anticoncepción",
|
||||
"testsSection": "Pruebas"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes del ciclo",
|
||||
|
||||
+10
-2
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Météo",
|
||||
"iconGroupPlaces": "Lieux",
|
||||
"iconGroupMisc": "Autres"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "Plus de tags",
|
||||
"sectionFactors": "Facteurs",
|
||||
"sectionNoteDetails": "Note et détails",
|
||||
"quickTagsHelp": "Vos tags fréquents — en un geste",
|
||||
"quickTagsExpand": "Plus"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Médicaments",
|
||||
@@ -5926,7 +5931,10 @@
|
||||
"contraceptive": "Contraception",
|
||||
"severity": "Intensité",
|
||||
"severityLevel": "Intensité {level} sur 4",
|
||||
"delete": "Supprimer"
|
||||
"delete": "Supprimer",
|
||||
"temperatureSection": "Température et ovulation",
|
||||
"intimacySection": "Intimité et contraception",
|
||||
"testsSection": "Tests"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Réglages du cycle",
|
||||
|
||||
+10
-2
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Meteo",
|
||||
"iconGroupPlaces": "Luoghi",
|
||||
"iconGroupMisc": "Altro"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "Altri tag",
|
||||
"sectionFactors": "Fattori",
|
||||
"sectionNoteDetails": "Nota e dettagli",
|
||||
"quickTagsHelp": "I tuoi tag frequenti: un tocco",
|
||||
"quickTagsExpand": "Altro"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Farmaci",
|
||||
@@ -5926,7 +5931,10 @@
|
||||
"contraceptive": "Contraccettivo",
|
||||
"severity": "Intensità",
|
||||
"severityLevel": "Intensità {level} su 4",
|
||||
"delete": "Elimina"
|
||||
"delete": "Elimina",
|
||||
"temperatureSection": "Temperatura e ovulazione",
|
||||
"intimacySection": "Intimità e contraccezione",
|
||||
"testsSection": "Test"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni del ciclo",
|
||||
|
||||
+10
-2
@@ -720,7 +720,12 @@
|
||||
"iconGroupWeather": "Pogoda",
|
||||
"iconGroupPlaces": "Miejsca",
|
||||
"iconGroupMisc": "Inne"
|
||||
}
|
||||
},
|
||||
"sectionMoreTags": "Więcej tagów",
|
||||
"sectionFactors": "Czynniki",
|
||||
"sectionNoteDetails": "Notatka i szczegóły",
|
||||
"quickTagsHelp": "Twoje częste tagi — jedno dotknięcie",
|
||||
"quickTagsExpand": "Więcej"
|
||||
},
|
||||
"medications": {
|
||||
"title": "Leki",
|
||||
@@ -5926,7 +5931,10 @@
|
||||
"contraceptive": "Antykoncepcja",
|
||||
"severity": "Nasilenie",
|
||||
"severityLevel": "Nasilenie {level} z 4",
|
||||
"delete": "Usuń"
|
||||
"delete": "Usuń",
|
||||
"temperatureSection": "Temperatura i owulacja",
|
||||
"intimacySection": "Intymność i antykoncepcja",
|
||||
"testsSection": "Testy"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ustawienia cyklu",
|
||||
|
||||
@@ -3,6 +3,11 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildDayLogPatch,
|
||||
buildDayLogInput,
|
||||
symptomsCount,
|
||||
temperatureCount,
|
||||
intimacyCount,
|
||||
testsCount,
|
||||
noteCount,
|
||||
type DayLogFormState,
|
||||
} from "../log-day-sheet";
|
||||
|
||||
@@ -136,3 +141,56 @@ describe("buildDayLogInput (new row → omit empties)", () => {
|
||||
expect("temperatureExcluded" in input).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("section summary-badge counts (v1.17.0)", () => {
|
||||
it("counts each logged symptom", () => {
|
||||
expect(symptomsCount(blank())).toBe(0);
|
||||
const s = { ...blank(), symptoms: new Map([["cramps", 2], ["headache", null]]) };
|
||||
expect(symptomsCount(s)).toBe(2);
|
||||
});
|
||||
|
||||
it("counts only the rendered symptothermal sign alongside BBT and OPK", () => {
|
||||
expect(temperatureCount(blank(), false)).toBe(0);
|
||||
// A non-finite / empty BBT string does not count.
|
||||
expect(temperatureCount({ ...blank(), bbt: "abc" }, false)).toBe(0);
|
||||
const s: DayLogFormState = {
|
||||
...blank(),
|
||||
bbt: "36.5",
|
||||
opk: "POSITIVE_LH_SURGE",
|
||||
mucus: "EGG_WHITE",
|
||||
cervixPosition: "HIGH",
|
||||
cervixFirmness: "SOFT",
|
||||
cervixOpening: "OPEN",
|
||||
};
|
||||
// Mucus surface (showCervix=false): BBT + OPK + mucus = 3; cervix signs
|
||||
// are not rendered, so they don't count.
|
||||
expect(temperatureCount(s, false)).toBe(3);
|
||||
// Cervix surface (showCervix=true): BBT + OPK + 3 cervix signs = 5; the
|
||||
// stale mucus value is not rendered, so it doesn't count.
|
||||
expect(temperatureCount(s, true)).toBe(5);
|
||||
});
|
||||
|
||||
it("counts intercourse and contraceptive", () => {
|
||||
expect(intimacyCount(blank())).toBe(0);
|
||||
expect(
|
||||
intimacyCount({ ...blank(), intercourse: true, contraceptive: "ORAL" }),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("counts each home-test result", () => {
|
||||
expect(testsCount(blank())).toBe(0);
|
||||
expect(
|
||||
testsCount({
|
||||
...blank(),
|
||||
pregnancyTest: "NEGATIVE",
|
||||
progesteroneTest: "POSITIVE",
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("counts a note only when it carries non-whitespace content", () => {
|
||||
expect(noteCount(blank())).toBe(0);
|
||||
expect(noteCount({ ...blank(), note: " " })).toBe(0);
|
||||
expect(noteCount({ ...blank(), note: "tired today" })).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
SheetSection,
|
||||
SheetSectionCount,
|
||||
} from "@/components/ui/sheet-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
|
||||
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
@@ -249,6 +253,62 @@ export function buildDayLogInput(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.0 — section summary-badge counts for the sectioned log-day sheet.
|
||||
*
|
||||
* Each helper reports how many fields a collapsed section holds, so the
|
||||
* badge communicates its contents without expanding it. Pure + exported
|
||||
* for the unit tests.
|
||||
*/
|
||||
export function symptomsCount(s: DayLogFormState): number {
|
||||
return s.symptoms.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temperature & ovulation: BBT reading, OPK, and the symptothermal secondary
|
||||
* sign. Only the sign the section actually renders is counted — mucus when
|
||||
* `showCervix` is false, the three cervix observations when true — so the
|
||||
* badge never reports a stale value from the surface that isn't shown (e.g.
|
||||
* after the user flips the advanced-settings secondary-symptom choice).
|
||||
*/
|
||||
export function temperatureCount(
|
||||
s: DayLogFormState,
|
||||
showCervix: boolean,
|
||||
): number {
|
||||
let n = 0;
|
||||
if (resolveBbt(s.bbt) != null) n += 1;
|
||||
if (s.opk) n += 1;
|
||||
if (showCervix) {
|
||||
if (s.cervixPosition) n += 1;
|
||||
if (s.cervixFirmness) n += 1;
|
||||
if (s.cervixOpening) n += 1;
|
||||
} else if (s.mucus) {
|
||||
n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Intimacy & contraception: intercourse logged, and any contraceptive. */
|
||||
export function intimacyCount(s: DayLogFormState): number {
|
||||
let n = 0;
|
||||
if (s.intercourse) n += 1;
|
||||
if (s.contraceptive) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Tests: pregnancy + progesterone home-test results. */
|
||||
export function testsCount(s: DayLogFormState): number {
|
||||
let n = 0;
|
||||
if (s.pregnancyTest) n += 1;
|
||||
if (s.progesteroneTest) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Note: 1 when the free-text note carries content. */
|
||||
export function noteCount(s: DayLogFormState): number {
|
||||
return s.note.trim() ? 1 : 0;
|
||||
}
|
||||
|
||||
export interface LogDaySheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -421,25 +481,28 @@ export function LogDaySheet({
|
||||
});
|
||||
}
|
||||
|
||||
// The live form state, shared by the save-payload builders and the section
|
||||
// summary badges (so a collapsed section reports what it holds).
|
||||
const formState: DayLogFormState = {
|
||||
flow,
|
||||
intermenstrual,
|
||||
bbt,
|
||||
bbtDisturbed,
|
||||
opk,
|
||||
mucus,
|
||||
cervixPosition,
|
||||
cervixFirmness,
|
||||
cervixOpening,
|
||||
intercourse,
|
||||
protectedSex,
|
||||
pregnancyTest,
|
||||
progesteroneTest,
|
||||
contraceptive,
|
||||
note,
|
||||
symptoms,
|
||||
};
|
||||
|
||||
async function handleSave() {
|
||||
const state: DayLogFormState = {
|
||||
flow,
|
||||
intermenstrual,
|
||||
bbt,
|
||||
bbtDisturbed,
|
||||
opk,
|
||||
mucus,
|
||||
cervixPosition,
|
||||
cervixFirmness,
|
||||
cervixOpening,
|
||||
intercourse,
|
||||
protectedSex,
|
||||
pregnancyTest,
|
||||
progesteroneTest,
|
||||
contraceptive,
|
||||
note,
|
||||
symptoms,
|
||||
};
|
||||
// v1.16.4 — catch so a rejected save doesn't escape as an unhandled
|
||||
// rejection; the inline `logDay.isError || patchDay.isError` strip in the
|
||||
// footer carries the visible failure signal and the sheet stays open.
|
||||
@@ -449,10 +512,10 @@ export function LogDaySheet({
|
||||
// chip actually CLEARS (the POST merge can only add/keep — QA W-2).
|
||||
await patchDay.mutateAsync({
|
||||
id: rowId,
|
||||
patch: buildDayLogPatch(state),
|
||||
patch: buildDayLogPatch(formState),
|
||||
});
|
||||
} else {
|
||||
await logDay.mutateAsync(buildDayLogInput(state, date));
|
||||
await logDay.mutateAsync(buildDayLogInput(formState, date));
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
@@ -573,7 +636,9 @@ export function LogDaySheet({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Flow */}
|
||||
{/* Quick row — Flow is the single most common period sign, so it
|
||||
stays always-open at the top alongside the intermenstrual toggle.
|
||||
Everything else lives in collapsible sections below. */}
|
||||
<Field label={t("cycle.sheet.flow")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FLOW_LEVELS.map((f) => (
|
||||
@@ -599,7 +664,10 @@ export function LogDaySheet({
|
||||
</Field>
|
||||
|
||||
{/* Symptoms */}
|
||||
<Field label={t("cycle.sheet.symptoms")}>
|
||||
<SheetSection
|
||||
title={t("cycle.sheet.symptoms")}
|
||||
summary={<SheetSectionCount count={symptomsCount(formState)} />}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{CYCLE_SYMPTOM_CATALOG.map((cat) => (
|
||||
<div key={cat.key} className="space-y-1.5">
|
||||
@@ -656,10 +724,18 @@ export function LogDaySheet({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</SheetSection>
|
||||
|
||||
{/* BBT */}
|
||||
<Field label={t("cycle.sheet.temperature")}>
|
||||
{/* Temperature & ovulation — BBT, OPK, and the symptothermal secondary
|
||||
sign (mucus or cervix) grouped under one disclosure. */}
|
||||
<SheetSection
|
||||
title={t("cycle.sheet.temperatureSection")}
|
||||
summary={
|
||||
<SheetSectionCount count={temperatureCount(formState, showCervix)} />
|
||||
}
|
||||
>
|
||||
{/* BBT */}
|
||||
<Field label={t("cycle.sheet.temperature")}>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
@@ -756,9 +832,15 @@ export function LogDaySheet({
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
{/* Intercourse + protection */}
|
||||
<Field label={t("cycle.sheet.intercourse")}>
|
||||
{/* Intimacy & contraception */}
|
||||
<SheetSection
|
||||
title={t("cycle.sheet.intimacySection")}
|
||||
summary={<SheetSectionCount count={intimacyCount(formState)} />}
|
||||
>
|
||||
{/* Intercourse + protection */}
|
||||
<Field label={t("cycle.sheet.intercourse")}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="cycle-intercourse" className="text-sm font-normal">
|
||||
@@ -786,57 +868,71 @@ export function LogDaySheet({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
</Field>
|
||||
|
||||
{/* Pregnancy test */}
|
||||
<Field label={t("cycle.sheet.pregnancyTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEST_RESULTS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={pregnancyTest === v}
|
||||
onClick={() => setPregnancyTest((cur) => (cur === v ? null : v))}
|
||||
>
|
||||
{t(`cycle.testResult.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
{/* Contraceptive */}
|
||||
<Field label={t("cycle.sheet.contraceptive")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CONTRACEPTIVE_KINDS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={contraceptive === v}
|
||||
onClick={() =>
|
||||
setContraceptive((cur) => (cur === v ? null : v))
|
||||
}
|
||||
>
|
||||
{t(`cycle.contraceptive.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</SheetSection>
|
||||
|
||||
{/* Progesterone test */}
|
||||
<Field label={t("cycle.sheet.progesteroneTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEST_RESULTS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={progesteroneTest === v}
|
||||
onClick={() =>
|
||||
setProgesteroneTest((cur) => (cur === v ? null : v))
|
||||
}
|
||||
>
|
||||
{t(`cycle.testResult.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
{/* Tests */}
|
||||
<SheetSection
|
||||
title={t("cycle.sheet.testsSection")}
|
||||
summary={<SheetSectionCount count={testsCount(formState)} />}
|
||||
>
|
||||
{/* Pregnancy test */}
|
||||
<Field label={t("cycle.sheet.pregnancyTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEST_RESULTS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={pregnancyTest === v}
|
||||
onClick={() =>
|
||||
setPregnancyTest((cur) => (cur === v ? null : v))
|
||||
}
|
||||
>
|
||||
{t(`cycle.testResult.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Contraceptive */}
|
||||
<Field label={t("cycle.sheet.contraceptive")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CONTRACEPTIVE_KINDS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={contraceptive === v}
|
||||
onClick={() => setContraceptive((cur) => (cur === v ? null : v))}
|
||||
>
|
||||
{t(`cycle.contraceptive.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
{/* Progesterone test */}
|
||||
<Field label={t("cycle.sheet.progesteroneTest")}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEST_RESULTS.map((v) => (
|
||||
<Chip
|
||||
key={v}
|
||||
active={progesteroneTest === v}
|
||||
onClick={() =>
|
||||
setProgesteroneTest((cur) => (cur === v ? null : v))
|
||||
}
|
||||
>
|
||||
{t(`cycle.testResult.${v}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</SheetSection>
|
||||
|
||||
{/* Note */}
|
||||
<Field label={t("cycle.sheet.note")}>
|
||||
<SheetSection
|
||||
title={t("cycle.sheet.note")}
|
||||
summary={<SheetSectionCount count={noteCount(formState)} />}
|
||||
>
|
||||
<Textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
@@ -845,7 +941,7 @@ export function LogDaySheet({
|
||||
placeholder={t("cycle.sheet.notePlaceholder")}
|
||||
aria-label={t("cycle.sheet.note")}
|
||||
/>
|
||||
</Field>
|
||||
</SheetSection>
|
||||
|
||||
{logDay.isError || patchDay.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { promoteRecentTags, selectQuickTagKeys } from "../recent-tags";
|
||||
|
||||
describe("promoteRecentTags", () => {
|
||||
it("prepends a freshly-used key, most-recent first", () => {
|
||||
expect(promoteRecentTags(["a", "b"], ["c"])).toEqual(["c", "a", "b"]);
|
||||
});
|
||||
|
||||
it("moves an already-present key to the front without duplicating", () => {
|
||||
expect(promoteRecentTags(["a", "b", "c"], ["b"])).toEqual([
|
||||
"b",
|
||||
"a",
|
||||
"c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the latest of a multi-key use at the front", () => {
|
||||
// The last key in `usedKeys` is the most recent → leads the list.
|
||||
expect(promoteRecentTags([], ["x", "y", "z"])).toEqual(["z", "y", "x"]);
|
||||
});
|
||||
|
||||
it("de-duplicates within the used keys", () => {
|
||||
expect(promoteRecentTags(["a"], ["a", "a"])).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("drops empty keys", () => {
|
||||
expect(promoteRecentTags(["a"], ["", "b"])).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("caps the history length", () => {
|
||||
const existing = Array.from({ length: 30 }, (_, i) => `k${i}`);
|
||||
const result = promoteRecentTags(existing, ["new"]);
|
||||
expect(result.length).toBe(24);
|
||||
expect(result[0]).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectQuickTagKeys", () => {
|
||||
const catalog = ["a", "b", "c", "d", "e"];
|
||||
|
||||
it("returns the MRU keys that still exist in the catalog", () => {
|
||||
expect(selectQuickTagKeys(["c", "a"], catalog, 3)).toEqual([
|
||||
"c",
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops MRU keys no longer in the catalog", () => {
|
||||
expect(selectQuickTagKeys(["gone", "b"], catalog, 2)).toEqual([
|
||||
"b",
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the first catalog keys with no history", () => {
|
||||
expect(selectQuickTagKeys([], catalog, 3)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("respects the limit", () => {
|
||||
expect(selectQuickTagKeys(["e", "d", "c"], catalog, 2)).toEqual([
|
||||
"e",
|
||||
"d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never duplicates when topping up from the catalog", () => {
|
||||
const result = selectQuickTagKeys(["b"], catalog, 5);
|
||||
expect(new Set(result).size).toBe(result.length);
|
||||
expect(result[0]).toBe("b");
|
||||
});
|
||||
});
|
||||
+198
-135
@@ -24,13 +24,27 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
ListChecks,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
SlidersHorizontal,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
SheetSection,
|
||||
SheetSectionCount,
|
||||
} from "@/components/ui/sheet-section";
|
||||
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
|
||||
import { MoodQuickTags } from "./mood-quick-tags";
|
||||
import { useRecentTags } from "./recent-tags";
|
||||
import { moodFaceIcon } from "./mood-tag-icons";
|
||||
|
||||
// v1.12.0 — best-on-the-left face order for the "How are you?" hero,
|
||||
@@ -116,6 +130,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
// fields the user actively fills count toward "dirty"; the timestamp
|
||||
// always carries an auto-populated default, so it is excluded.
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
// v1.17.0 — MRU tag history powers the always-open Quick row; tapping the
|
||||
// Quick row's "+" expands the full "More tags" section (controlled so the
|
||||
// chip can open it).
|
||||
const { recent, recordUse } = useRecentTags();
|
||||
const [moreTagsOpen, setMoreTagsOpen] = useState(false);
|
||||
const isDirty =
|
||||
mood !== "" ||
|
||||
tagsInput.trim() !== "" ||
|
||||
@@ -193,7 +212,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
moodLoggedAt: timestamp,
|
||||
});
|
||||
|
||||
// v1.17.0 — promote the saved structured tags to the Quick row's MRU
|
||||
// history so the next entry surfaces them as one-tap chips.
|
||||
if (tagKeys.length > 0) recordUse(tagKeys);
|
||||
resetForm();
|
||||
setMoreTagsOpen(false);
|
||||
await invalidateKeys(queryClient, moodDependentKeys);
|
||||
toast.success(t("common.saved"));
|
||||
onSuccess?.();
|
||||
@@ -259,6 +282,16 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
// primary input and a fast log is a single tap + Save.
|
||||
const moodPicked = mood !== "";
|
||||
|
||||
// v1.17.0 — the "Note & details" badge counts the free-text tags entered
|
||||
// plus the note (when present), so a collapsed section communicates what
|
||||
// it holds. The auto-populated timestamp is excluded (it always carries a
|
||||
// default, so it would never read as "empty").
|
||||
const freeTextTagCount = tagsInput
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean).length;
|
||||
const detailsCount = freeTextTagCount + (note.trim() ? 1 : 0);
|
||||
|
||||
return (
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* v1.12.0 — "How are you?" 5-face hero. The five faces are a
|
||||
@@ -318,152 +351,182 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
</div>
|
||||
|
||||
{moodPicked && (
|
||||
<div
|
||||
className="space-y-4 border-t pt-4"
|
||||
data-slot="mood-annotate-panel"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
|
||||
<DateTimeInput
|
||||
id="mood-logged-at"
|
||||
value={moodLoggedAt}
|
||||
onChange={(e) => setMoodLoggedAt(e.target.value)}
|
||||
// v1.17 W1b — match the server bound: no future instant.
|
||||
max={getDefaultMoodLoggedAtValue()}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3" data-slot="mood-annotate-panel">
|
||||
{/* v1.17.0 — always-open Quick row: MRU one-tap tags + a "+" that
|
||||
expands the full "More tags" section. The common case stays a
|
||||
face + a couple of taps + Save. */}
|
||||
<MoodQuickTags
|
||||
selected={tagKeys}
|
||||
onToggle={toggleTagKey}
|
||||
recent={recent}
|
||||
onExpand={() => setMoreTagsOpen(true)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="tags">
|
||||
{t("mood.tags")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("mood.tagsHelp")}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
id="tags"
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder={t("mood.tagsPlaceholder")}
|
||||
enterKeyHint="done"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
{/* v1.17.0 — "More tags": the full structured-tag taxonomy
|
||||
(binary tiles + custom). Controlled so the Quick row's "+" can
|
||||
open it; the badge reports how many tags are selected. */}
|
||||
<SheetSection
|
||||
title={t("mood.sectionMoreTags")}
|
||||
icon={<ListChecks />}
|
||||
open={moreTagsOpen}
|
||||
onOpenChange={setMoreTagsOpen}
|
||||
summary={<SheetSectionCount count={tagKeys.length} />}
|
||||
>
|
||||
<MoodTagPicker
|
||||
selected={tagKeys}
|
||||
onToggle={toggleTagKey}
|
||||
mode="binary"
|
||||
/>
|
||||
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
|
||||
appends the localised label to the free-text tag list.
|
||||
Always visible for now (cheap UX; the Coach side-effect
|
||||
aggregator filters on the canonical English tag set so the
|
||||
German labels still register correctly). */}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.sideEffectTagsHelp")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
|
||||
const label = t(key);
|
||||
const tags = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim().toLowerCase());
|
||||
const isActive = tags.includes(label.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
const next = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(
|
||||
(p) => p.toLowerCase() !== label.toLowerCase(),
|
||||
);
|
||||
setTagsInput(next.join(", "));
|
||||
} else {
|
||||
const next = tagsInput.trim()
|
||||
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
|
||||
: label;
|
||||
setTagsInput(next);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
|
||||
isActive
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{/* v1.8.5 / v1.12.0 — structured-tag taxonomy picker. Additive next
|
||||
to the free-text input above; an entry can carry both axes. The
|
||||
picker also surfaces the FACTOR-kind tags as 1..scaleMax rating
|
||||
controls, lifted here as `ratedFactors`. */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("mood.tagPicker")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
{/* 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"
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="mood-note">
|
||||
{t("mood.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
|
||||
the note field, and the timestamp. The badge counts whichever
|
||||
of these carry input. */}
|
||||
<SheetSection
|
||||
title={t("mood.sectionNoteDetails")}
|
||||
icon={<StickyNote />}
|
||||
summary={<SheetSectionCount count={detailsCount} />}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="tags">
|
||||
{t("mood.tags")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("mood.tagsHelp")}
|
||||
</span>
|
||||
</Label>
|
||||
{/* v1.11.5 — character counter so the `maxLength` cap no longer
|
||||
truncates silently. Turns destructive (warns) as the input
|
||||
approaches the limit. */}
|
||||
<span
|
||||
data-testid="mood-note-counter"
|
||||
className={`text-xs tabular-nums ${
|
||||
note.length >= NOTE_MAX_LENGTH
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{t("mood.noteCharCount", {
|
||||
count: String(note.length),
|
||||
max: String(NOTE_MAX_LENGTH),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
id="tags"
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder={t("mood.tagsPlaceholder")}
|
||||
enterKeyHint="done"
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
|
||||
appends the localised label to the free-text tag list.
|
||||
Always visible for now (cheap UX; the Coach side-effect
|
||||
aggregator filters on the canonical English tag set so the
|
||||
German labels still register correctly). */}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.sideEffectTagsHelp")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
|
||||
const label = t(key);
|
||||
const tags = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim().toLowerCase());
|
||||
const isActive = tags.includes(label.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isActive) {
|
||||
const next = tagsInput
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(
|
||||
(p) => p.toLowerCase() !== label.toLowerCase(),
|
||||
);
|
||||
setTagsInput(next.join(", "));
|
||||
} else {
|
||||
const next = tagsInput.trim()
|
||||
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
|
||||
: label;
|
||||
setTagsInput(next);
|
||||
}
|
||||
}}
|
||||
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
|
||||
isActive
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
id="mood-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={NOTE_MAX_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="mood-note">
|
||||
{t("mood.note")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
{/* v1.11.5 — character counter so the `maxLength` cap no
|
||||
longer truncates silently. Turns destructive (warns) as the
|
||||
input approaches the limit. */}
|
||||
<span
|
||||
data-testid="mood-note-counter"
|
||||
className={`text-xs tabular-nums ${
|
||||
note.length >= NOTE_MAX_LENGTH
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{t("mood.noteCharCount", {
|
||||
count: String(note.length),
|
||||
max: String(NOTE_MAX_LENGTH),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
id="mood-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("mood.notePlaceholder")}
|
||||
maxLength={NOTE_MAX_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
|
||||
<DateTimeInput
|
||||
id="mood-logged-at"
|
||||
value={moodLoggedAt}
|
||||
onChange={(e) => setMoodLoggedAt(e.target.value)}
|
||||
// v1.17 W1b — match the server bound: no future instant.
|
||||
max={getDefaultMoodLoggedAtValue()}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={errorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { moodTagIcon } from "./mood-tag-icons";
|
||||
import { selectQuickTagKeys } from "./recent-tags";
|
||||
|
||||
/**
|
||||
* v1.17.0 — the always-open Quick row at the top of the mood add-sheet.
|
||||
*
|
||||
* Surfaces the caller's most-recently-used binary tags (falling back to the
|
||||
* first catalogue tags on a fresh account) as one-tap chips, so the common
|
||||
* case is a face + a couple of familiar taps + Save. A trailing "+" expands
|
||||
* the full "More tags" section for everything else.
|
||||
*
|
||||
* Reads the same `/api/mood/tags` catalogue as `MoodTagPicker` (shared query
|
||||
* key → one network round-trip). Renders nothing while the catalogue is
|
||||
* empty so a cleared taxonomy degrades gracefully.
|
||||
*/
|
||||
|
||||
interface CatalogTag {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
label?: string | null;
|
||||
icon: string | null;
|
||||
kind: "BINARY" | "RATED";
|
||||
}
|
||||
|
||||
interface CatalogCategory {
|
||||
tags: CatalogTag[];
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
categories: CatalogCategory[];
|
||||
}
|
||||
|
||||
const QUICK_LIMIT = 8;
|
||||
|
||||
export function MoodQuickTags({
|
||||
selected,
|
||||
onToggle,
|
||||
recent,
|
||||
onExpand,
|
||||
}: {
|
||||
/** Selected binary tag keys (shared with `MoodTagPicker`). */
|
||||
selected: string[];
|
||||
onToggle: (tagKey: string) => void;
|
||||
/** MRU tag keys, most-recent first (from `useRecentTags`). */
|
||||
recent: readonly string[];
|
||||
/** Opens the full "More tags" section. */
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.moodTagCatalog(),
|
||||
queryFn: async () => apiGet<CatalogResponse>("/api/mood/tags"),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-11 w-full rounded-lg" />;
|
||||
}
|
||||
if (!data || data.categories.length === 0) return null;
|
||||
|
||||
// Flatten the catalogue to binary tags only; the Quick row never carries
|
||||
// rated factors (those live under the "Factors" section).
|
||||
const binaryTags = data.categories
|
||||
.flatMap((c) => c.tags)
|
||||
.filter((tag) => tag.kind !== "RATED");
|
||||
if (binaryTags.length === 0) return null;
|
||||
|
||||
const tagByKey = new Map(binaryTags.map((tag) => [tag.key, tag]));
|
||||
const catalogKeys = binaryTags.map((tag) => tag.key);
|
||||
const selectedSet = new Set(selected);
|
||||
|
||||
// Show the MRU/fallback quick keys, but always include any currently
|
||||
// selected key so a tap from the full picker stays visible up here too.
|
||||
const quickKeys = selectQuickTagKeys(recent, catalogKeys, QUICK_LIMIT);
|
||||
const orderedKeys = [
|
||||
...quickKeys,
|
||||
...selected.filter((k) => tagByKey.has(k) && !quickKeys.includes(k)),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-slot="mood-quick-tags">
|
||||
<p className="text-muted-foreground text-xs">{t("mood.quickTagsHelp")}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{orderedKeys.map((key) => {
|
||||
const tag = tagByKey.get(key);
|
||||
if (!tag) return null;
|
||||
const TagIcon = moodTagIcon(tag.icon);
|
||||
const label = tag.label ?? t(tag.labelKey);
|
||||
const isActive = selectedSet.has(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onToggle(key)}
|
||||
aria-pressed={isActive}
|
||||
data-slot="mood-quick-tag"
|
||||
className={`inline-flex min-h-11 items-center gap-1.5 rounded-full border px-3 py-2 text-xs transition-colors ${
|
||||
isActive
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<TagIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
aria-label={t("mood.quickTagsExpand")}
|
||||
title={t("mood.quickTagsExpand")}
|
||||
data-slot="mood-quick-tags-expand"
|
||||
className="border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded-full border border-dashed px-3 py-2 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("mood.quickTagsExpand")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ export function MoodTagPicker({
|
||||
onToggle,
|
||||
ratedFactors = [],
|
||||
onRateFactor,
|
||||
mode = "all",
|
||||
}: {
|
||||
/** Selected binary tag keys (sent as `tagKeys`). */
|
||||
selected: string[];
|
||||
@@ -89,6 +90,13 @@ export function MoodTagPicker({
|
||||
* when omitted the picker only surfaces binary tags.
|
||||
*/
|
||||
onRateFactor?: (key: string, rating: number | null) => void;
|
||||
/**
|
||||
* v1.17.0 — which tag kinds to surface. The sectioned add-sheet renders
|
||||
* the binary tiles under "More tags" (`mode="binary"`) and the segmented
|
||||
* factor ratings under "Factors" (`mode="rated"`) as two separate
|
||||
* sections; the default `"all"` keeps the legacy interleaved layout.
|
||||
*/
|
||||
mode?: "all" | "binary" | "rated";
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -116,6 +124,8 @@ export function MoodTagPicker({
|
||||
|
||||
const selectedSet = new Set(selected);
|
||||
const ratingByKey = new Map(ratedFactors.map((f) => [f.key, f.rating]));
|
||||
const showBinary = mode !== "rated";
|
||||
const showRated = mode !== "binary";
|
||||
|
||||
const groupOptions = data.categories.map((category) => ({
|
||||
key: category.key,
|
||||
@@ -131,10 +141,15 @@ export function MoodTagPicker({
|
||||
<div className="space-y-4" data-slot="mood-tag-picker">
|
||||
{data.categories.map((category) => {
|
||||
const CategoryIcon = moodTagIcon(category.icon);
|
||||
const binaryTags = category.tags.filter((tag) => tag.kind !== "RATED");
|
||||
const ratedTags = category.tags.filter((tag) => tag.kind === "RATED");
|
||||
const binaryTags = showBinary
|
||||
? category.tags.filter((tag) => tag.kind !== "RATED")
|
||||
: [];
|
||||
const ratedTags = showRated
|
||||
? category.tags.filter((tag) => tag.kind === "RATED")
|
||||
: [];
|
||||
// Self-gate an empty category (e.g. a RATED-only category when no
|
||||
// `onRateFactor` is wired) so the picker never shows a bare heading.
|
||||
// `onRateFactor` is wired, or a mode that filters all tags out) so
|
||||
// the picker never shows a bare heading.
|
||||
if (binaryTags.length === 0 && ratedTags.length === 0) return null;
|
||||
if (ratedTags.length > 0 && !onRateFactor && binaryTags.length === 0) {
|
||||
return null;
|
||||
@@ -147,7 +162,7 @@ export function MoodTagPicker({
|
||||
<span>{category.label ?? t(category.labelKey)}</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
{showBinary && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{binaryTags.map((tag) => {
|
||||
const TagIcon = moodTagIcon(tag.icon);
|
||||
@@ -178,7 +193,7 @@ export function MoodTagPicker({
|
||||
onClick={() => setCreateGroupKey(category.key)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
|
||||
{ratedTags.length > 0 && onRateFactor && (
|
||||
<div className="space-y-2" data-slot="mood-factor-ratings">
|
||||
@@ -262,8 +277,9 @@ export function MoodTagPicker({
|
||||
|
||||
{/* Bootstrap node for the seeded Custom group: before the first
|
||||
custom tag exists the plain read carries no `custom` category,
|
||||
so the inline-create entry point renders synthetically. */}
|
||||
{!hasCustomGroup && (
|
||||
so the inline-create entry point renders synthetically. Only the
|
||||
binary-tag surfaces it (custom tags are binary). */}
|
||||
{showBinary && !hasCustomGroup && (
|
||||
<div className="space-y-2" data-slot="mood-tag-custom-bootstrap">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
<Tag className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* v1.17.0 — most-recently-used mood-tag tracking for the Quick row.
|
||||
*
|
||||
* The Quick row surfaces the caller's handful of most-recently-used binary
|
||||
* tags as one-tap chips so the common case (a face + a couple of familiar
|
||||
* tags + Save) is genuinely fast. The MRU order is a small, client-only
|
||||
* convenience persisted in `localStorage`; it never touches the API and a
|
||||
* cleared store simply degrades to "no quick chips yet".
|
||||
*
|
||||
* The selection logic is split out as pure functions so it is unit-testable
|
||||
* without a DOM.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "healthlog:mood:recent-tags";
|
||||
const MAX_RECENT = 24;
|
||||
|
||||
/**
|
||||
* Promote `usedKeys` to the front of the existing MRU list (most-recent
|
||||
* first), de-duplicated, capped at `MAX_RECENT`. A key already present is
|
||||
* moved to the front rather than duplicated. Order within `usedKeys` is
|
||||
* preserved (first item ends up most-recent).
|
||||
*/
|
||||
export function promoteRecentTags(
|
||||
existing: readonly string[],
|
||||
usedKeys: readonly string[],
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const next: string[] = [];
|
||||
// Most-recent first: the latest-used keys lead, then prior history.
|
||||
for (const key of [...usedKeys].reverse().concat(existing)) {
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
next.push(key);
|
||||
if (next.length >= MAX_RECENT) break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the ordered Quick-row keys for a render: the MRU keys that still
|
||||
* exist in the live catalog (intersected so a deleted tag drops out), capped
|
||||
* at `limit`. When the user has no history yet, fall back to the first
|
||||
* `limit` catalog keys so the Quick row is never empty on a fresh account.
|
||||
*/
|
||||
export function selectQuickTagKeys(
|
||||
recent: readonly string[],
|
||||
catalogKeys: readonly string[],
|
||||
limit: number,
|
||||
): string[] {
|
||||
const catalogSet = new Set(catalogKeys);
|
||||
const fromHistory = recent.filter((k) => catalogSet.has(k));
|
||||
if (fromHistory.length >= limit) return fromHistory.slice(0, limit);
|
||||
// Top up with catalog keys not already chosen so the row stays full.
|
||||
const chosen = new Set(fromHistory);
|
||||
const topped = [...fromHistory];
|
||||
for (const key of catalogKeys) {
|
||||
if (topped.length >= limit) break;
|
||||
if (!chosen.has(key)) {
|
||||
chosen.add(key);
|
||||
topped.push(key);
|
||||
}
|
||||
}
|
||||
return topped.slice(0, limit);
|
||||
}
|
||||
|
||||
const EMPTY: string[] = [];
|
||||
// Cache the parsed snapshot so `useSyncExternalStore` gets a stable
|
||||
// reference between reads (a fresh array every call would loop forever).
|
||||
let snapshot: string[] = EMPTY;
|
||||
let snapshotRaw: string | null | undefined;
|
||||
|
||||
function readStore(): string[] {
|
||||
if (typeof window === "undefined") return EMPTY;
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
if (raw === snapshotRaw) return snapshot;
|
||||
snapshotRaw = raw;
|
||||
if (!raw) {
|
||||
snapshot = EMPTY;
|
||||
return snapshot;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
snapshot = Array.isArray(parsed)
|
||||
? parsed.filter((x): x is string => typeof x === "string")
|
||||
: EMPTY;
|
||||
} catch {
|
||||
snapshot = EMPTY;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
function subscribe(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
}
|
||||
function emit() {
|
||||
for (const cb of listeners) cb();
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook over the MRU store. Reads the persisted keys via
|
||||
* `useSyncExternalStore` (SSR-safe: the server snapshot is empty and
|
||||
* hydration stays stable), and exposes a `recordUse` to promote
|
||||
* freshly-saved tag keys to the front.
|
||||
*/
|
||||
export function useRecentTags(): {
|
||||
recent: string[];
|
||||
recordUse: (usedKeys: readonly string[]) => void;
|
||||
} {
|
||||
const recent = useSyncExternalStore(subscribe, readStore, () => EMPTY);
|
||||
|
||||
const recordUse = useCallback((usedKeys: readonly string[]) => {
|
||||
if (usedKeys.length === 0 || typeof window === "undefined") return;
|
||||
const next = promoteRecentTags(readStore(), usedKeys);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* storage full / disabled — nothing to promote */
|
||||
return;
|
||||
}
|
||||
// Invalidate the cached snapshot and notify subscribers to re-read.
|
||||
snapshotRaw = undefined;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
return { recent, recordUse };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { SheetSection, SheetSectionCount } from "../sheet-section";
|
||||
|
||||
describe("<SheetSection>", () => {
|
||||
it("renders the title and summary slot on the trigger row", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetSection title="More tags" summary={<span>summary-badge</span>}>
|
||||
<p>section body</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
expect(html).toContain("More tags");
|
||||
expect(html).toContain("summary-badge");
|
||||
});
|
||||
|
||||
it("renders children inside an open section", () => {
|
||||
// Radix Collapsible omits the content subtree while collapsed, so the
|
||||
// body is asserted on an expanded section.
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetSection title="More tags" defaultOpen>
|
||||
<p>section body</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
expect(html).toContain("section body");
|
||||
});
|
||||
|
||||
it("starts collapsed by default and open when defaultOpen", () => {
|
||||
const closed = renderToStaticMarkup(
|
||||
<SheetSection title="Factors">
|
||||
<p>x</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
const open = renderToStaticMarkup(
|
||||
<SheetSection title="Factors" defaultOpen>
|
||||
<p>x</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
expect(closed).toContain('data-state="closed"');
|
||||
expect(open).toContain('data-state="open"');
|
||||
});
|
||||
|
||||
it("hides the leading icon from assistive tech", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetSection title="Note" icon={<svg data-testid="icon" />}>
|
||||
<p>x</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
expect(html).toMatch(/aria-hidden="true"[^>]*>[^<]*<svg/);
|
||||
});
|
||||
|
||||
it("respects reduced motion on the content and chevron", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetSection title="x">
|
||||
<p>y</p>
|
||||
</SheetSection>,
|
||||
);
|
||||
expect(html).toContain("motion-reduce:animate-none");
|
||||
expect(html).toContain("motion-reduce:transition-none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SheetSectionCount>", () => {
|
||||
it("renders the count when positive", () => {
|
||||
const html = renderToStaticMarkup(<SheetSectionCount count={3} />);
|
||||
expect(html).toContain("3");
|
||||
expect(html).toContain('data-slot="sheet-section-count"');
|
||||
});
|
||||
|
||||
it("renders nothing for zero or negative counts", () => {
|
||||
expect(renderToStaticMarkup(<SheetSectionCount count={0} />)).toBe("");
|
||||
expect(renderToStaticMarkup(<SheetSectionCount count={-1} />)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.17.0 — `SheetSection` is the collapsible disclosure primitive that
|
||||
* powers the sectioned single-sheet add flows (mood, cycle). Each section
|
||||
* carries a title, an optional leading icon, and a `summary` slot that
|
||||
* communicates what is selected inside it while collapsed (e.g. a count
|
||||
* badge) — so a closed section never hides its contents silently.
|
||||
*
|
||||
* Built on Radix Collapsible. The chevron rotates and the content height
|
||||
* animates open/closed via the `tw-animate-css` `collapsible-down/up`
|
||||
* keyframes; both motions are suppressed under `prefers-reduced-motion`.
|
||||
* Matches the shadcn new-york + zinc token language used across the app.
|
||||
*/
|
||||
|
||||
interface SheetSectionProps {
|
||||
/** Section heading shown on the always-visible trigger row. */
|
||||
title: string;
|
||||
/**
|
||||
* A node rendered on the trigger row's trailing edge — typically a count
|
||||
* badge that reports what is selected inside while the section is
|
||||
* collapsed. Stays visible whether the section is open or closed.
|
||||
*/
|
||||
summary?: React.ReactNode;
|
||||
/** Optional leading icon (a Lucide component or any node). */
|
||||
icon?: React.ReactNode;
|
||||
/** Whether the section starts expanded (uncontrolled). Defaults to closed. */
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state. When set, pair with `onOpenChange`. */
|
||||
open?: boolean;
|
||||
/** Controlled open-state change handler. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Optional className merged onto the section wrapper. */
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SheetSection({
|
||||
title,
|
||||
summary,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
className,
|
||||
children,
|
||||
}: SheetSectionProps) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Root
|
||||
// Controlled when `open` is provided, otherwise uncontrolled.
|
||||
{...(open === undefined ? { defaultOpen } : { open, onOpenChange })}
|
||||
data-slot="sheet-section"
|
||||
className={cn("border-border/60 border-t pt-3", className)}
|
||||
>
|
||||
<CollapsiblePrimitive.Trigger
|
||||
data-slot="sheet-section-trigger"
|
||||
className="group focus-visible:ring-ring/50 -mx-1 flex w-[calc(100%+0.5rem)] items-center gap-2 rounded-md px-1 py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{icon ? (
|
||||
<span
|
||||
className="text-muted-foreground flex h-4 w-4 shrink-0 items-center justify-center [&>svg]:h-4 [&>svg]:w-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-foreground flex-1 text-sm font-medium">
|
||||
{title}
|
||||
</span>
|
||||
{summary ? (
|
||||
<span
|
||||
data-slot="sheet-section-summary"
|
||||
className="flex items-center"
|
||||
>
|
||||
{summary}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
</CollapsiblePrimitive.Trigger>
|
||||
<CollapsiblePrimitive.Content
|
||||
data-slot="sheet-section-content"
|
||||
className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none"
|
||||
>
|
||||
<div className="space-y-3 pt-3 pb-1">{children}</div>
|
||||
</CollapsiblePrimitive.Content>
|
||||
</CollapsiblePrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact "(N)" count badge for a `SheetSection` summary slot. Renders
|
||||
* nothing when `count <= 0`, so a section with no selections shows a bare
|
||||
* chevron rather than a "(0)".
|
||||
*/
|
||||
export function SheetSectionCount({ count }: { count: number }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<span
|
||||
data-slot="sheet-section-count"
|
||||
className="bg-primary/15 text-primary inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user