"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"; import { InlineAction } from "@/components/ui/inline-action"; 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)} />