diff --git a/messages/de.json b/messages/de.json index 83bf2a774..debcaf22c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index f8f791de8..0562c627d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index d42236d4d..97276f21a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index bca12109c..4e5057a54 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 0fa6d76a7..e83bbd6b5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index bf977d2aa..60fc2564f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/mood/mood-context-fields.tsx b/src/components/mood/mood-context-fields.tsx new file mode 100644 index 000000000..533e04384 --- /dev/null +++ b/src/components/mood/mood-context-fields.tsx @@ -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>; + +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 + ).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 | 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> & { + 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 ( + + ); +} + +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 ( +
+ +
+ {keys.map((key) => ( + onChange(value === key ? null : key)} + /> + ))} +
+
+ ); +} + +function MultiChoiceRow({ + label, + field, + keys, + value, + onChange, +}: { + label: string; + field: string; + keys: readonly string[]; + value: string[]; + onChange: (next: string[]) => void; +}) { + const { t } = useTranslations(); + return ( +
+ +
+ {keys.map((key) => ( + + onChange( + value.includes(key) + ? value.filter((k) => k !== key) + : [...value, key], + ) + } + /> + ))} +
+
+ ); +} + +function MinutesField({ + id, + label, + value, + onChange, +}: { + id: string; + label: string; + value: number | null; + onChange: (next: number | null) => void; +}) { + const { t } = useTranslations(); + return ( + + { + 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)); + }} + /> + + ); +} + +/** 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 ( + onChange(null)} + onValueChange={onChange} + /> + ); +} + +interface MoodContextSectionsProps { + value: MoodContextState; + onChange: (next: MoodContextState) => void; +} + +export function MoodContextSections({ + value, + onChange, +}: MoodContextSectionsProps) { + const { t } = useTranslations(); + const set = ( + key: K, + next: MoodContextState[K], + ) => onChange({ ...value, [key]: next }); + + return ( + <> + } + summary={ + + } + > +
+ set("workStatus", next)} + /> + set("workMinutes", next)} + /> + set("overtimeMinutes", next)} + /> + set("workLoad", next)} + /> + set("workSatisfaction", next)} + /> +
+
+ + } + summary={ + + } + > +
+

+ {t("mood.dayContext.contactsHelp")} +

+ set("contactCircles", next)} + /> + set("contactForm", next)} + /> + set("contactExtent", next)} + /> + set("contactQuality", next)} + /> + set("contactSupport", next)} + /> +
+
+ + } + summary={ + + } + > +
+ set("leisureCategories", next)} + /> + set("leisureMinutes", next)} + /> + set("leisureJoy", next)} + /> + set("leisureRecovery", next)} + /> +
+
+ + } + summary={ + + } + > +
+ set("eventType", next)} + /> + set("eventValence", null)} + onValueChange={(next) => set("eventValence", next)} + /> + + set("eventAt", next)} + /> + + +