mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(mood): four context sections that cost nothing while closed
- work, contacts, leisure and one notable event, each a closed accordion with a count badge, on both the capture sheet and the edit dialog - sleep, activity, vitals and the illness journal render read-only beside them, each row naming where its figure comes from and offering a way in to change it there - the body line links into the illness module and captures nothing - six locales for every label and every anchor
This commit is contained in:
1 parent
1f609d42e0
commit
0474b7161d
10 files changed
+943
No files matched your search
@@ -1031,6 +1031,23 @@
|
||||
"high": "deutlich gut"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dayContext": {
|
||||
"minutesPlaceholder": "Minuten",
|
||||
"contactsHelp": "Wie viele Menschen es waren, sagt nichts über den Tag. Was zählt, ist wie der Kontakt sich angefühlt hat.",
|
||||
"notePlaceholder": "Was zu diesem Tag noch gehört",
|
||||
"notRecorded": "nicht erfasst",
|
||||
"linkedTitle": "Aus deinen anderen Modulen",
|
||||
"linkedHelp": "Gelesen, wo es gepflegt wird. Änderst du es dort, ändert es sich auch hier.",
|
||||
"linkedSleep": "Geschlafen",
|
||||
"linkedInBed": "Im Bett",
|
||||
"linkedSteps": "Schritte",
|
||||
"linkedActiveEnergy": "Aktive Energie",
|
||||
"linkedRestingHeartRate": "Ruhepuls",
|
||||
"linkedHrv": "Herzratenvariabilität",
|
||||
"bodyLogged": "Im Krankheitstagebuch erfasst: {count} Symptome",
|
||||
"bodyNotLogged": "Für diesen Tag ist im Krankheitstagebuch nichts erfasst",
|
||||
"openIllness": "Öffnen"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
@@ -1031,6 +1031,23 @@
|
||||
"high": "clearly good"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dayContext": {
|
||||
"minutesPlaceholder": "Minutes",
|
||||
"contactsHelp": "How many people it was says nothing about the day. What counts is how the contact felt.",
|
||||
"notePlaceholder": "Anything else that belongs to this day",
|
||||
"notRecorded": "not recorded",
|
||||
"linkedTitle": "From your other modules",
|
||||
"linkedHelp": "Read from where it is kept. Change it there and it changes here.",
|
||||
"linkedSleep": "Asleep",
|
||||
"linkedInBed": "In bed",
|
||||
"linkedSteps": "Steps",
|
||||
"linkedActiveEnergy": "Active energy",
|
||||
"linkedRestingHeartRate": "Resting heart rate",
|
||||
"linkedHrv": "Heart rate variability",
|
||||
"bodyLogged": "Logged in the illness journal: {count} symptoms",
|
||||
"bodyNotLogged": "Nothing is logged in the illness journal for this day",
|
||||
"openIllness": "Open"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
@@ -1031,6 +1031,23 @@
|
||||
"high": "claramente bueno"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dayContext": {
|
||||
"minutesPlaceholder": "Minutos",
|
||||
"contactsHelp": "Cuántas personas fueron no dice nada del día. Lo que cuenta es cómo se sintió el contacto.",
|
||||
"notePlaceholder": "Cualquier otra cosa que forme parte de este día",
|
||||
"notRecorded": "sin registrar",
|
||||
"linkedTitle": "De tus otros módulos",
|
||||
"linkedHelp": "Se lee de donde está guardado. Cámbialo allí y cambia aquí.",
|
||||
"linkedSleep": "Dormido",
|
||||
"linkedInBed": "En la cama",
|
||||
"linkedSteps": "Pasos",
|
||||
"linkedActiveEnergy": "Energía activa",
|
||||
"linkedRestingHeartRate": "Pulso en reposo",
|
||||
"linkedHrv": "Variabilidad cardiaca",
|
||||
"bodyLogged": "Registrado en el diario de enfermedad: {count} síntomas",
|
||||
"bodyNotLogged": "No hay nada registrado en el diario de enfermedad para este día",
|
||||
"openIllness": "Abrir"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
@@ -1031,6 +1031,23 @@
|
||||
"high": "nettement positif"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dayContext": {
|
||||
"minutesPlaceholder": "Minutes",
|
||||
"contactsHelp": "Le nombre de personnes ne dit rien de la journée. Ce qui compte, c'est le ressenti du contact.",
|
||||
"notePlaceholder": "Ce qui appartient encore à cette journée",
|
||||
"notRecorded": "non enregistré",
|
||||
"linkedTitle": "Depuis tes autres modules",
|
||||
"linkedHelp": "Lu là où c'est enregistré. Modifie-le là-bas et cela change ici.",
|
||||
"linkedSleep": "Sommeil",
|
||||
"linkedInBed": "Au lit",
|
||||
"linkedSteps": "Pas",
|
||||
"linkedActiveEnergy": "Énergie active",
|
||||
"linkedRestingHeartRate": "Fréquence cardiaque au repos",
|
||||
"linkedHrv": "Variabilité cardiaque",
|
||||
"bodyLogged": "Consigné dans le journal de maladie : {count} symptômes",
|
||||
"bodyNotLogged": "Rien n'est consigné dans le journal de maladie pour cette journée",
|
||||
"openIllness": "Ouvrir"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
@@ -1031,6 +1031,23 @@
|
||||
"high": "chiaramente positivo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dayContext": {
|
||||
"minutesPlaceholder": "Minuti",
|
||||
"contactsHelp": "Quante persone fossero non dice nulla della giornata. Conta come è stato vissuto il contatto.",
|
||||
"notePlaceholder": "Che altro appartiene a questa giornata",
|
||||
"notRecorded": "non registrato",
|
||||
"linkedTitle": "Dagli altri moduli",
|
||||
"linkedHelp": "Letto da dove è registrato. Cambialo lì e cambia anche qui.",
|
||||
"linkedSleep": "Dormito",
|
||||
"linkedInBed": "A letto",
|
||||
"linkedSteps": "Passi",
|
||||
"linkedActiveEnergy": "Energia attiva",
|
||||
"linkedRestingHeartRate": "Frequenza cardiaca a riposo",
|
||||
"linkedHrv": "Variabilità cardiaca",
|
||||
"bodyLogged": "Registrato nel diario di malattia: {count} sintomi",
|
||||
"bodyNotLogged": "Per questa giornata non c'è nulla nel diario di malattia",
|
||||
"openIllness": "Apri"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
@@ -1031,6 +1031,23 @@
|
||||
"high": "wyraźnie dobry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dayContext": {
|
||||
"minutesPlaceholder": "Minuty",
|
||||
"contactsHelp": "Liczba osób nic nie mówi o dniu. Liczy się to, jak kontakt został odebrany.",
|
||||
"notePlaceholder": "Co jeszcze należy do tego dnia",
|
||||
"notRecorded": "nie zapisano",
|
||||
"linkedTitle": "Z pozostałych modułów",
|
||||
"linkedHelp": "Odczytane stamtąd, gdzie jest przechowywane. Zmień to tam, a zmieni się tutaj.",
|
||||
"linkedSleep": "Sen",
|
||||
"linkedInBed": "W łóżku",
|
||||
"linkedSteps": "Kroki",
|
||||
"linkedActiveEnergy": "Energia aktywna",
|
||||
"linkedRestingHeartRate": "Tętno spoczynkowe",
|
||||
"linkedHrv": "Zmienność rytmu serca",
|
||||
"bodyLogged": "Zapisano w dzienniku choroby: {count} objawów",
|
||||
"bodyNotLogged": "Na ten dzień nic nie zapisano w dzienniku choroby",
|
||||
"openIllness": "Otwórz"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The four day-context sections, and the read-only block beside them.
|
||||
*
|
||||
* One component for both mood surfaces — the create sheet and the list's edit
|
||||
* dialog — for the same reason the level-A sliders share one: what one writes
|
||||
* the other has to be able to show, and a value somebody set on Monday and
|
||||
* cannot correct on Tuesday is worse than one they were never offered.
|
||||
*
|
||||
* Every section is closed by default and renders no inputs while closed, so an
|
||||
* entry is still one tap and Save. The count badge on a closed section says
|
||||
* how much is in it, so nothing is hidden silently.
|
||||
*
|
||||
* Two copy rules hold throughout, and both are load-bearing rather than
|
||||
* stylistic. Labels state facts and never judgements: "worked a regular day"
|
||||
* is a fact and "worked too much" is a verdict on somebody's life. And the
|
||||
* linked block is read-only — it shows what the sleep, activity, vitals and
|
||||
* illness modules already hold, names where each figure comes from, and offers
|
||||
* a way in to change it there. It is never an input here, because the moment
|
||||
* it were, the same night would have two homes.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Briefcase,
|
||||
CalendarClock,
|
||||
Link2,
|
||||
Palmtree,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
|
||||
import { SliderField } from "@/components/ui/slider";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
CONTACT_CIRCLE_KEYS,
|
||||
CONTACT_EXTENT_KEYS,
|
||||
CONTACT_FORM_KEYS,
|
||||
CONTEXT_MINUTES_MAX,
|
||||
CONTEXT_MINUTES_MIN,
|
||||
CONTEXT_RATING_MAX,
|
||||
CONTEXT_RATING_MIN,
|
||||
CONTEXT_VALENCE_MAX,
|
||||
CONTEXT_VALENCE_MIN,
|
||||
EVENT_TYPE_KEYS,
|
||||
LEISURE_CATEGORY_KEYS,
|
||||
WORK_STATUS_KEYS,
|
||||
contextSectionLabelKey,
|
||||
contextValueLabelKey,
|
||||
} from "@/lib/mood/context-vocabulary";
|
||||
import type { LinkedDayContext, LinkedFigure } from "@/lib/mood/linked-context";
|
||||
|
||||
const CONTEXT_NOTE_MAX_LENGTH = 500;
|
||||
|
||||
/** The context as the form holds it. `null` everywhere means unanswered. */
|
||||
export interface MoodContextState {
|
||||
workStatus: string | null;
|
||||
workMinutes: number | null;
|
||||
overtimeMinutes: number | null;
|
||||
workLoad: number | null;
|
||||
workSatisfaction: number | null;
|
||||
contactCircles: string[];
|
||||
contactForm: string | null;
|
||||
contactExtent: string | null;
|
||||
contactQuality: number | null;
|
||||
contactSupport: number | null;
|
||||
leisureCategories: string[];
|
||||
leisureMinutes: number | null;
|
||||
leisureJoy: number | null;
|
||||
leisureRecovery: number | null;
|
||||
eventType: string | null;
|
||||
eventValence: number | null;
|
||||
/** Local `datetime-local` value, or "" for unanswered. */
|
||||
eventAt: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export const EMPTY_MOOD_CONTEXT: MoodContextState = {
|
||||
workStatus: null,
|
||||
workMinutes: null,
|
||||
overtimeMinutes: null,
|
||||
workLoad: null,
|
||||
workSatisfaction: null,
|
||||
contactCircles: [],
|
||||
contactForm: null,
|
||||
contactExtent: null,
|
||||
contactQuality: null,
|
||||
contactSupport: null,
|
||||
leisureCategories: [],
|
||||
leisureMinutes: null,
|
||||
leisureJoy: null,
|
||||
leisureRecovery: null,
|
||||
eventType: null,
|
||||
eventValence: null,
|
||||
eventAt: "",
|
||||
note: "",
|
||||
};
|
||||
|
||||
/** Which state fields each section owns, so a badge counts its own section. */
|
||||
const SECTION_FIELDS = {
|
||||
work: [
|
||||
"workStatus",
|
||||
"workMinutes",
|
||||
"overtimeMinutes",
|
||||
"workLoad",
|
||||
"workSatisfaction",
|
||||
],
|
||||
contacts: [
|
||||
"contactCircles",
|
||||
"contactForm",
|
||||
"contactExtent",
|
||||
"contactQuality",
|
||||
"contactSupport",
|
||||
],
|
||||
leisure: [
|
||||
"leisureCategories",
|
||||
"leisureMinutes",
|
||||
"leisureJoy",
|
||||
"leisureRecovery",
|
||||
],
|
||||
events: ["eventType", "eventValence", "eventAt", "note"],
|
||||
} as const satisfies Record<string, ReadonlyArray<keyof MoodContextState>>;
|
||||
|
||||
function isAnswered(value: MoodContextState[keyof MoodContextState]): boolean {
|
||||
if (value === null) return false;
|
||||
if (Array.isArray(value)) return value.length > 0;
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
return true;
|
||||
}
|
||||
|
||||
/** How many answers a section holds — the collapsed section's badge. */
|
||||
export function contextSectionCount(
|
||||
state: MoodContextState,
|
||||
section: keyof typeof SECTION_FIELDS,
|
||||
): number {
|
||||
return SECTION_FIELDS[section].filter((field) => isAnswered(state[field]))
|
||||
.length;
|
||||
}
|
||||
|
||||
/** Does the whole context say anything at all? */
|
||||
export function contextIsEmpty(state: MoodContextState): boolean {
|
||||
return (
|
||||
Object.keys(SECTION_FIELDS) as Array<keyof typeof SECTION_FIELDS>
|
||||
).every((section) => contextSectionCount(state, section) === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The context as the API takes it.
|
||||
*
|
||||
* `null` when nothing was answered, which is how the write path is told to
|
||||
* store no row (and to remove one that is there). Not `undefined`: on the edit
|
||||
* path an omitted key means "leave it alone", and a person who cleared every
|
||||
* section means the opposite of that.
|
||||
*/
|
||||
export function moodContextPayload(
|
||||
state: MoodContextState,
|
||||
): Record<string, unknown> | null {
|
||||
if (contextIsEmpty(state)) return null;
|
||||
return {
|
||||
workStatus: state.workStatus,
|
||||
workMinutes: state.workMinutes,
|
||||
overtimeMinutes: state.overtimeMinutes,
|
||||
workLoad: state.workLoad,
|
||||
workSatisfaction: state.workSatisfaction,
|
||||
contactCircles: state.contactCircles,
|
||||
contactForm: state.contactForm,
|
||||
contactExtent: state.contactExtent,
|
||||
contactQuality: state.contactQuality,
|
||||
contactSupport: state.contactSupport,
|
||||
leisureCategories: state.leisureCategories,
|
||||
leisureMinutes: state.leisureMinutes,
|
||||
leisureJoy: state.leisureJoy,
|
||||
leisureRecovery: state.leisureRecovery,
|
||||
eventType: state.eventType,
|
||||
eventValence: state.eventValence,
|
||||
eventAt: state.eventAt ? new Date(state.eventAt).toISOString() : null,
|
||||
note: state.note.trim() === "" ? null : state.note.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Seed the form from an entry being edited. */
|
||||
export function moodContextFromEntry(
|
||||
context:
|
||||
| (Partial<Omit<MoodContextState, "eventAt" | "note">> & {
|
||||
eventAt?: string | null;
|
||||
note?: string | null;
|
||||
})
|
||||
| null
|
||||
| undefined,
|
||||
): MoodContextState {
|
||||
if (!context) return EMPTY_MOOD_CONTEXT;
|
||||
return {
|
||||
...EMPTY_MOOD_CONTEXT,
|
||||
...context,
|
||||
contactCircles: context.contactCircles ?? [],
|
||||
leisureCategories: context.leisureCategories ?? [],
|
||||
// The wire carries an ISO instant; the control wants a local wall clock.
|
||||
eventAt: context.eventAt ? toLocalInputValue(context.eventAt) : "",
|
||||
note: context.note ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function toLocalInputValue(iso: string): string {
|
||||
const at = new Date(iso);
|
||||
const local = new Date(at.getTime() - at.getTimezoneOffset() * 60 * 1000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/** A selectable chip. Tapping the selected one again takes the answer back. */
|
||||
function ChoiceChip({
|
||||
label,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
data-slot="mood-context-choice"
|
||||
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary/15 text-primary"
|
||||
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceRow({
|
||||
label,
|
||||
field,
|
||||
keys,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
field: string;
|
||||
keys: readonly string[];
|
||||
value: string | null;
|
||||
onChange: (next: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-xs font-normal">
|
||||
{label}
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keys.map((key) => (
|
||||
<ChoiceChip
|
||||
key={key}
|
||||
label={t(contextValueLabelKey(field, key))}
|
||||
selected={value === key}
|
||||
onClick={() => onChange(value === key ? null : key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiChoiceRow({
|
||||
label,
|
||||
field,
|
||||
keys,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
field: string;
|
||||
keys: readonly string[];
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground text-xs font-normal">
|
||||
{label}
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keys.map((key) => (
|
||||
<ChoiceChip
|
||||
key={key}
|
||||
label={t(contextValueLabelKey(field, key))}
|
||||
selected={value.includes(key)}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
value.includes(key)
|
||||
? value.filter((k) => k !== key)
|
||||
: [...value, key],
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MinutesField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number | null;
|
||||
onChange: (next: number | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<FieldGroup htmlFor={id} label={label}>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={CONTEXT_MINUTES_MIN}
|
||||
max={CONTEXT_MINUTES_MAX}
|
||||
step={5}
|
||||
value={value === null ? "" : String(value)}
|
||||
placeholder={t("mood.dayContext.minutesPlaceholder")}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
// An emptied field is an unanswered field, not a zero. Zero minutes
|
||||
// is a real answer somebody can still type.
|
||||
onChange(raw === "" ? null : Number(raw));
|
||||
}}
|
||||
/>
|
||||
</FieldGroup>
|
||||
);
|
||||
}
|
||||
|
||||
/** One 0-10 rating with its two anchors, resolved from the vocabulary. */
|
||||
function ContextRating({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: string;
|
||||
value: number | null;
|
||||
onChange: (next: number | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const low = t(`mood.context.rating.${field}.low`);
|
||||
const high = t(`mood.context.rating.${field}.high`);
|
||||
return (
|
||||
<SliderField
|
||||
label={t(contextValueLabelKey("field", field))}
|
||||
value={value}
|
||||
min={CONTEXT_RATING_MIN}
|
||||
max={CONTEXT_RATING_MAX}
|
||||
step={1}
|
||||
lowAnchor={low}
|
||||
highAnchor={high}
|
||||
emptyLabel={t("mood.dimensionNotAnswered")}
|
||||
valueText={
|
||||
value === null
|
||||
? undefined
|
||||
: t("mood.dimensionValueText", {
|
||||
value,
|
||||
max: CONTEXT_RATING_MAX,
|
||||
low,
|
||||
high,
|
||||
})
|
||||
}
|
||||
clearLabel={t("mood.dimensionClear")}
|
||||
onClear={() => onChange(null)}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface MoodContextSectionsProps {
|
||||
value: MoodContextState;
|
||||
onChange: (next: MoodContextState) => void;
|
||||
}
|
||||
|
||||
export function MoodContextSections({
|
||||
value,
|
||||
onChange,
|
||||
}: MoodContextSectionsProps) {
|
||||
const { t } = useTranslations();
|
||||
const set = <K extends keyof MoodContextState>(
|
||||
key: K,
|
||||
next: MoodContextState[K],
|
||||
) => onChange({ ...value, [key]: next });
|
||||
|
||||
return (
|
||||
<>
|
||||
<SheetSection
|
||||
title={t(contextSectionLabelKey("work"))}
|
||||
icon={<Briefcase />}
|
||||
summary={
|
||||
<SheetSectionCount count={contextSectionCount(value, "work")} />
|
||||
}
|
||||
>
|
||||
<div className="space-y-4" data-slot="mood-context-work">
|
||||
<ChoiceRow
|
||||
label={t(contextValueLabelKey("field", "workStatus"))}
|
||||
field="workStatus"
|
||||
keys={WORK_STATUS_KEYS}
|
||||
value={value.workStatus}
|
||||
onChange={(next) => set("workStatus", next)}
|
||||
/>
|
||||
<MinutesField
|
||||
id="mood-context-work-minutes"
|
||||
label={t(contextValueLabelKey("field", "workMinutes"))}
|
||||
value={value.workMinutes}
|
||||
onChange={(next) => set("workMinutes", next)}
|
||||
/>
|
||||
<MinutesField
|
||||
id="mood-context-overtime-minutes"
|
||||
label={t(contextValueLabelKey("field", "overtimeMinutes"))}
|
||||
value={value.overtimeMinutes}
|
||||
onChange={(next) => set("overtimeMinutes", next)}
|
||||
/>
|
||||
<ContextRating
|
||||
field="workLoad"
|
||||
value={value.workLoad}
|
||||
onChange={(next) => set("workLoad", next)}
|
||||
/>
|
||||
<ContextRating
|
||||
field="workSatisfaction"
|
||||
value={value.workSatisfaction}
|
||||
onChange={(next) => set("workSatisfaction", next)}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection
|
||||
title={t(contextSectionLabelKey("contacts"))}
|
||||
icon={<Users />}
|
||||
summary={
|
||||
<SheetSectionCount count={contextSectionCount(value, "contacts")} />
|
||||
}
|
||||
>
|
||||
<div className="space-y-4" data-slot="mood-context-contacts">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("mood.dayContext.contactsHelp")}
|
||||
</p>
|
||||
<MultiChoiceRow
|
||||
label={t(contextValueLabelKey("field", "contactCircles"))}
|
||||
field="contactCircles"
|
||||
keys={CONTACT_CIRCLE_KEYS}
|
||||
value={value.contactCircles}
|
||||
onChange={(next) => set("contactCircles", next)}
|
||||
/>
|
||||
<ChoiceRow
|
||||
label={t(contextValueLabelKey("field", "contactForm"))}
|
||||
field="contactForm"
|
||||
keys={CONTACT_FORM_KEYS}
|
||||
value={value.contactForm}
|
||||
onChange={(next) => set("contactForm", next)}
|
||||
/>
|
||||
<ChoiceRow
|
||||
label={t(contextValueLabelKey("field", "contactExtent"))}
|
||||
field="contactExtent"
|
||||
keys={CONTACT_EXTENT_KEYS}
|
||||
value={value.contactExtent}
|
||||
onChange={(next) => set("contactExtent", next)}
|
||||
/>
|
||||
<ContextRating
|
||||
field="contactQuality"
|
||||
value={value.contactQuality}
|
||||
onChange={(next) => set("contactQuality", next)}
|
||||
/>
|
||||
<ContextRating
|
||||
field="contactSupport"
|
||||
value={value.contactSupport}
|
||||
onChange={(next) => set("contactSupport", next)}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection
|
||||
title={t(contextSectionLabelKey("leisure"))}
|
||||
icon={<Palmtree />}
|
||||
summary={
|
||||
<SheetSectionCount count={contextSectionCount(value, "leisure")} />
|
||||
}
|
||||
>
|
||||
<div className="space-y-4" data-slot="mood-context-leisure">
|
||||
<MultiChoiceRow
|
||||
label={t(contextValueLabelKey("field", "leisureCategories"))}
|
||||
field="leisureCategories"
|
||||
keys={LEISURE_CATEGORY_KEYS}
|
||||
value={value.leisureCategories}
|
||||
onChange={(next) => set("leisureCategories", next)}
|
||||
/>
|
||||
<MinutesField
|
||||
id="mood-context-leisure-minutes"
|
||||
label={t(contextValueLabelKey("field", "leisureMinutes"))}
|
||||
value={value.leisureMinutes}
|
||||
onChange={(next) => set("leisureMinutes", next)}
|
||||
/>
|
||||
<ContextRating
|
||||
field="leisureJoy"
|
||||
value={value.leisureJoy}
|
||||
onChange={(next) => set("leisureJoy", next)}
|
||||
/>
|
||||
<ContextRating
|
||||
field="leisureRecovery"
|
||||
value={value.leisureRecovery}
|
||||
onChange={(next) => set("leisureRecovery", next)}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection
|
||||
title={t(contextSectionLabelKey("events"))}
|
||||
icon={<CalendarClock />}
|
||||
summary={
|
||||
<SheetSectionCount count={contextSectionCount(value, "events")} />
|
||||
}
|
||||
>
|
||||
<div className="space-y-4" data-slot="mood-context-events">
|
||||
<ChoiceRow
|
||||
label={t(contextValueLabelKey("field", "eventType"))}
|
||||
field="eventType"
|
||||
keys={EVENT_TYPE_KEYS}
|
||||
value={value.eventType}
|
||||
onChange={(next) => set("eventType", next)}
|
||||
/>
|
||||
<SliderField
|
||||
label={t(contextValueLabelKey("field", "eventValence"))}
|
||||
value={value.eventValence}
|
||||
min={CONTEXT_VALENCE_MIN}
|
||||
max={CONTEXT_VALENCE_MAX}
|
||||
step={1}
|
||||
lowAnchor={t("mood.context.rating.eventValence.low")}
|
||||
highAnchor={t("mood.context.rating.eventValence.high")}
|
||||
emptyLabel={t("mood.dimensionNotAnswered")}
|
||||
clearLabel={t("mood.dimensionClear")}
|
||||
onClear={() => set("eventValence", null)}
|
||||
onValueChange={(next) => set("eventValence", next)}
|
||||
/>
|
||||
<FieldGroup
|
||||
htmlFor="mood-context-event-at"
|
||||
label={t(contextValueLabelKey("field", "eventAt"))}
|
||||
>
|
||||
<DateTimeField
|
||||
id="mood-context-event-at"
|
||||
value={value.eventAt}
|
||||
onChange={(next) => set("eventAt", next)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<FieldGroup
|
||||
htmlFor="mood-context-note"
|
||||
label={t(contextValueLabelKey("field", "notes"))}
|
||||
>
|
||||
<Textarea
|
||||
id="mood-context-note"
|
||||
value={value.note}
|
||||
onChange={(e) => set("note", e.target.value)}
|
||||
placeholder={t("mood.dayContext.notePlaceholder")}
|
||||
maxLength={CONTEXT_NOTE_MAX_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</SheetSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFigure(
|
||||
figure: LinkedFigure | undefined,
|
||||
notRecorded: string,
|
||||
): string {
|
||||
if (!figure || !figure.present) return notRecorded;
|
||||
return `${figure.value} ${figure.unit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-only block: what the other modules already hold for this day.
|
||||
*
|
||||
* Every row names where its number comes from and offers a way in to change it
|
||||
* there. A block whose module is switched off is not rendered at all — not
|
||||
* zeroed, not greyed out — because turning a module off is a statement about
|
||||
* what somebody wants to see.
|
||||
*/
|
||||
export function MoodLinkedDaySection({
|
||||
linked,
|
||||
}: {
|
||||
linked: LinkedDayContext | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const notRecorded = t("mood.dayContext.notRecorded");
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!linked) return [];
|
||||
const out: Array<{ label: string; value: string; href: string }> = [];
|
||||
if (linked.sleep.available) {
|
||||
out.push({
|
||||
label: t("mood.dayContext.linkedSleep"),
|
||||
value: formatFigure(linked.sleep.asleep, notRecorded),
|
||||
href: "/insights/sleep",
|
||||
});
|
||||
out.push({
|
||||
label: t("mood.dayContext.linkedInBed"),
|
||||
value: formatFigure(linked.sleep.inBed, notRecorded),
|
||||
href: "/insights/sleep",
|
||||
});
|
||||
}
|
||||
if (linked.activity.available) {
|
||||
out.push({
|
||||
label: t("mood.dayContext.linkedSteps"),
|
||||
value: formatFigure(linked.activity.steps, notRecorded),
|
||||
href: "/insights/steps",
|
||||
});
|
||||
out.push({
|
||||
label: t("mood.dayContext.linkedActiveEnergy"),
|
||||
value: formatFigure(linked.activity.activeEnergy, notRecorded),
|
||||
href: "/insights/steps",
|
||||
});
|
||||
}
|
||||
if (linked.vitals.available) {
|
||||
out.push({
|
||||
label: t("mood.dayContext.linkedRestingHeartRate"),
|
||||
value: formatFigure(linked.vitals.restingHeartRate, notRecorded),
|
||||
href: "/measurements",
|
||||
});
|
||||
out.push({
|
||||
label: t("mood.dayContext.linkedHrv"),
|
||||
value: formatFigure(linked.vitals.heartRateVariability, notRecorded),
|
||||
href: "/measurements",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [linked, notRecorded, t]);
|
||||
|
||||
if (!linked) return null;
|
||||
|
||||
const bodyLine = linked.body.available
|
||||
? linked.body.logged
|
||||
? t("mood.dayContext.bodyLogged", {
|
||||
count: String(linked.body.symptoms.length),
|
||||
})
|
||||
: t("mood.dayContext.bodyNotLogged")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SheetSection
|
||||
title={t("mood.dayContext.linkedTitle")}
|
||||
icon={<Link2 />}
|
||||
summary={<SheetSectionCount count={rows.length} />}
|
||||
>
|
||||
<div className="space-y-3" data-slot="mood-linked-day">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("mood.dayContext.linkedHelp")}
|
||||
</p>
|
||||
<dl className="space-y-1.5">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<dt className="text-muted-foreground text-xs">{row.label}</dt>
|
||||
<dd className="text-foreground text-sm tabular-nums">
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{bodyLine ? (
|
||||
<div className="border-border/60 flex items-center justify-between gap-3 border-t pt-3">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Activity className="mr-1 inline h-3 w-3" aria-hidden="true" />
|
||||
{bodyLine}
|
||||
</span>
|
||||
<a
|
||||
href={
|
||||
linked.body.available && linked.body.episodeId
|
||||
? `/illness/${linked.body.episodeId}`
|
||||
: "/illness"
|
||||
}
|
||||
className="text-primary text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("mood.dayContext.openIllness")}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,15 @@ import {
|
||||
seedA1FromMood,
|
||||
type LevelAState,
|
||||
} from "./mood-level-a-fields";
|
||||
import {
|
||||
EMPTY_MOOD_CONTEXT,
|
||||
MoodContextSections,
|
||||
MoodLinkedDaySection,
|
||||
contextIsEmpty,
|
||||
moodContextPayload,
|
||||
type MoodContextState,
|
||||
} from "./mood-context-fields";
|
||||
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
|
||||
import { MoodQuickTags } from "./mood-quick-tags";
|
||||
import { useRecentTags } from "./recent-tags";
|
||||
import { moodFaceIcon } from "./mood-tag-icons";
|
||||
@@ -128,6 +137,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
const [levelA, setLevelA] = useState<LevelAState>(EMPTY_LEVEL_A);
|
||||
const [a1Touched, setA1Touched] = useState(false);
|
||||
const [levelAOpen, setLevelAOpen] = useState(false);
|
||||
// v1.38 — the day's context. Four closed sections; nothing here is required
|
||||
// and an entry that never opens one stores no context row at all.
|
||||
const [context, setContext] = useState<MoodContextState>(EMPTY_MOOD_CONTEXT);
|
||||
const [tagsInput, setTagsInput] = useState("");
|
||||
// v1.8.5 — structured-tag keys picked from the taxonomy catalog.
|
||||
const [tagKeys, setTagKeys] = useState<string[]>([]);
|
||||
@@ -154,6 +166,9 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
// 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) ||
|
||||
// A filled context section is typed input too: Reset asks before it
|
||||
// discards a day somebody described.
|
||||
!contextIsEmpty(context) ||
|
||||
note.trim() !== "";
|
||||
|
||||
// v1.37 — picking a face suggests a pleasantness value; the suggestion
|
||||
@@ -184,6 +199,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
setLevelA(EMPTY_LEVEL_A);
|
||||
setA1Touched(false);
|
||||
setLevelAOpen(false);
|
||||
setContext(EMPTY_MOOD_CONTEXT);
|
||||
setTagsInput("");
|
||||
setTagKeys([]);
|
||||
setNote("");
|
||||
@@ -225,6 +241,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
// v1.37 — only the values somebody answered. An untouched slider
|
||||
// sends nothing, and the server writes NULL rather than a middle.
|
||||
...levelACreatePayload(levelA),
|
||||
// v1.38 — omitted when nothing was answered, so the create stores no
|
||||
// context row rather than a row of NULLs.
|
||||
...(contextIsEmpty(context)
|
||||
? {}
|
||||
: { context: moodContextPayload(context) }),
|
||||
moodLoggedAt: timestamp,
|
||||
});
|
||||
|
||||
@@ -295,6 +316,12 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
// primary input and a fast log is a single tap + Save.
|
||||
const moodPicked = mood !== "";
|
||||
|
||||
// v1.38 — what the other modules already hold for the day being logged.
|
||||
// Keyed on the timestamp field, so moving the entry to yesterday shows
|
||||
// yesterday's night. Only fetched once a face is picked: an unopened sheet
|
||||
// must not cost four modules a query.
|
||||
const linked = useLinkedDayContext(dayOfLocalInput(moodLoggedAt), moodPicked);
|
||||
|
||||
// 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.
|
||||
@@ -420,6 +447,17 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
|
||||
onOpenChange={setLevelAOpen}
|
||||
/>
|
||||
|
||||
{/* v1.38 — the day around the entry: work, contacts, leisure and a
|
||||
notable event. Four closed sections; opening none of them is a
|
||||
complete entry. */}
|
||||
<MoodContextSections value={context} onChange={setContext} />
|
||||
|
||||
{/* v1.38 — read-only, and read from the modules that own it. Sleep,
|
||||
activity and vitals are never asked for a second time here; the
|
||||
body line links into the illness journal instead of growing a
|
||||
second symptom form. */}
|
||||
<MoodLinkedDaySection linked={linked} />
|
||||
|
||||
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
|
||||
and the note field. The badge counts whichever of these carry
|
||||
input. */}
|
||||
|
||||
@@ -94,6 +94,16 @@ import {
|
||||
levelAUpdatePayload,
|
||||
type LevelAState,
|
||||
} from "./mood-level-a-fields";
|
||||
import type { MoodContextWire } from "@/lib/mood/context";
|
||||
import {
|
||||
EMPTY_MOOD_CONTEXT,
|
||||
MoodContextSections,
|
||||
MoodLinkedDaySection,
|
||||
moodContextFromEntry,
|
||||
moodContextPayload,
|
||||
type MoodContextState,
|
||||
} from "./mood-context-fields";
|
||||
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
|
||||
@@ -118,6 +128,8 @@ interface MoodEntry {
|
||||
a4?: number | null;
|
||||
a5?: number | null;
|
||||
note: string | null;
|
||||
// v1.38 — the day context, null on entries that carry none.
|
||||
context: MoodContextWire | null;
|
||||
source: string;
|
||||
moodLoggedAt: string;
|
||||
}
|
||||
@@ -201,6 +213,12 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
// 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);
|
||||
// v1.38 — the day context on the edit path, seeded from the entry so what
|
||||
// the capture sheet wrote is visible and correctable here. A value that can
|
||||
// be written on one surface and not corrected on another is worse than one
|
||||
// nobody was offered.
|
||||
const [editContext, setEditContext] =
|
||||
useState<MoodContextState>(EMPTY_MOOD_CONTEXT);
|
||||
const [editNote, setEditNote] = useState("");
|
||||
const [editMoodLoggedAt, setEditMoodLoggedAt] = useState("");
|
||||
const [editSeed, setEditSeed] = useState<{
|
||||
@@ -209,6 +227,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tagKeys: string[];
|
||||
ratedFactors: RatedFactor[];
|
||||
levelA: LevelAState;
|
||||
context: MoodContextState;
|
||||
note: string;
|
||||
moodLoggedAt: string;
|
||||
}>({
|
||||
@@ -217,6 +236,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tagKeys: [],
|
||||
ratedFactors: [],
|
||||
levelA: EMPTY_LEVEL_A,
|
||||
context: EMPTY_MOOD_CONTEXT,
|
||||
note: "",
|
||||
moodLoggedAt: "",
|
||||
});
|
||||
@@ -410,6 +430,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tagKeys,
|
||||
ratedFactors,
|
||||
levelA,
|
||||
context,
|
||||
note,
|
||||
moodLoggedAt,
|
||||
}: {
|
||||
@@ -419,6 +440,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tagKeys: string[];
|
||||
ratedFactors: RatedFactor[];
|
||||
levelA: LevelAState;
|
||||
context: MoodContextState;
|
||||
note: string | null;
|
||||
moodLoggedAt: string;
|
||||
}) => {
|
||||
@@ -431,6 +453,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
// user cleared has to travel as an explicit null or the old answer
|
||||
// survives an edit that visibly removed it.
|
||||
...levelAUpdatePayload(levelA),
|
||||
// Always stated on this path, and `null` when every section is empty:
|
||||
// the dialog shows the whole context, so an omission here would leave
|
||||
// a context the person just cleared sitting on the row.
|
||||
context: moodContextPayload(context),
|
||||
note,
|
||||
moodLoggedAt,
|
||||
});
|
||||
@@ -446,6 +472,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: editLevelA,
|
||||
context: editContext,
|
||||
note: editNote,
|
||||
moodLoggedAt: editMoodLoggedAt,
|
||||
});
|
||||
@@ -465,12 +492,21 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: editLevelA,
|
||||
context: editContext,
|
||||
note: editNote,
|
||||
moodLoggedAt: editMoodLoggedAt,
|
||||
},
|
||||
blocked: updateMutation.isPending || deleteMutation.isPending,
|
||||
});
|
||||
|
||||
// v1.38 — the linked figures for the entry being edited, read for its own
|
||||
// local day. Only while a dialog is open: a list of fifty rows must not cost
|
||||
// fifty module reads.
|
||||
const editLinked = useLinkedDayContext(
|
||||
dayOfLocalInput(editMoodLoggedAt),
|
||||
editing !== null,
|
||||
);
|
||||
|
||||
const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0;
|
||||
|
||||
function startEdit(entry: MoodEntry) {
|
||||
@@ -482,6 +518,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
a.key.localeCompare(b.key),
|
||||
),
|
||||
levelA: levelAFromEntry(entry),
|
||||
context: moodContextFromEntry(entry.context),
|
||||
note: entry.note ?? "",
|
||||
moodLoggedAt: toDateTimeLocalValue(entry.moodLoggedAt),
|
||||
};
|
||||
@@ -491,6 +528,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
setEditTagKeys(seed.tagKeys);
|
||||
setEditRatedFactors(seed.ratedFactors);
|
||||
setEditLevelA(seed.levelA);
|
||||
setEditContext(seed.context);
|
||||
setEditNote(seed.note);
|
||||
setEditMoodLoggedAt(seed.moodLoggedAt);
|
||||
setEditSeed(seed);
|
||||
@@ -544,6 +582,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
tagKeys: editTagKeys,
|
||||
ratedFactors: editRatedFactors,
|
||||
levelA: editLevelA,
|
||||
context: editContext,
|
||||
note: trimmedNote.length > 0 ? trimmedNote : null,
|
||||
moodLoggedAt: measuredDate.toISOString(),
|
||||
});
|
||||
@@ -1044,6 +1083,17 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
|
||||
set. */}
|
||||
<MoodLevelASection value={editLevelA} onChange={setEditLevelA} />
|
||||
|
||||
{/* v1.38 — the day context, seeded from the entry. */}
|
||||
<MoodContextSections
|
||||
value={editContext}
|
||||
onChange={setEditContext}
|
||||
/>
|
||||
|
||||
{/* v1.38 — read-only, from the modules that own it. Resolved for
|
||||
the entry's own day, so an entry moved to another date shows
|
||||
that day's figures. */}
|
||||
<MoodLinkedDaySection linked={editLinked} />
|
||||
|
||||
{/* v1.8.5 (C1) — free-text note. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-mood-note">
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Read what the other modules already hold for one local day.
|
||||
*
|
||||
* A hook rather than a prop drilled from a page, because both mood surfaces
|
||||
* need it and both know only the wall-clock value in their timestamp field.
|
||||
* The zone comes from the browser, which is the same zone the account's
|
||||
* `displayTimezone` follows for anyone who has not deliberately set it
|
||||
* otherwise; the server re-resolves the day either way, so a mismatch shows up
|
||||
* as a different day's figures rather than as wrong figures for this one.
|
||||
*
|
||||
* The query key carries both the day and the zone. One key for both would let
|
||||
* whichever answer arrived first serve the other — the same-key /
|
||||
* different-shape cache poisoning the key factory exists to prevent, in its
|
||||
* quieter same-shape / different-window form.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { LinkedDayContext } from "@/lib/mood/linked-context";
|
||||
|
||||
/** The browser's own zone, which is what the capture sheet is showing. */
|
||||
function browserTimezone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` for a `datetime-local` value, or today when it is empty. */
|
||||
export function dayOfLocalInput(value: string): string {
|
||||
if (value.length >= 10) return value.slice(0, 10);
|
||||
const now = new Date();
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000);
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function useLinkedDayContext(day: string, enabled = true) {
|
||||
const tz = browserTimezone();
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.moodLinkedContext(day, tz),
|
||||
queryFn: () =>
|
||||
apiGet<LinkedDayContext>(
|
||||
`/api/mood/linked-context?date=${encodeURIComponent(day)}&tz=${encodeURIComponent(tz)}`,
|
||||
),
|
||||
enabled,
|
||||
// The figures come from other modules and change on their own schedule; a
|
||||
// minute of staleness is cheaper than refetching four modules every time
|
||||
// the sheet re-renders.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return query.data ?? null;
|
||||
}
|
||||
Reference in new issue
Block a user