mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The chronic / pre-existing conditions the Coach watches lived under the personal-context "About me" panel, but they belong with the rest of the medical history. Move the conditions and the "what should the Coach watch" focus into Settings -> Anamnese, next to allergies and family history, and make them editable inline there. The store is unchanged: both fields are still part of the /api/coach/about-me payload, read and written through the same query key, so the Coach and every other consumer keep reading the same data. The About-me panel keeps the free-text note and the allergies line and omits the two conditions fields on save, leaving them untouched.
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* v1.15.20 — Settings → AI "About me" panel (free text only).
|
|
* v1.16.0 — structured self-context: alongside the free text the user
|
|
* notes allergies / intolerances. All fields are encrypted at rest;
|
|
* age/gender stay on the profile and are merged into the prompt
|
|
* server-side — the panel says so instead of asking again.
|
|
*
|
|
* v1.25.12 — the pre-existing / chronic conditions and the "what should the
|
|
* Coach watch" focus moved out of this panel into Settings → Anamnese, where
|
|
* they sit with allergies + family history as one medical history. Editing them
|
|
* has a single home there now; this panel keeps the free-text note and the
|
|
* allergies line. The store is unchanged — every field is still part of the
|
|
* `/api/coach/about-me` payload; the PUT here simply omits the two conditions
|
|
* fields, which are preserved server-side.
|
|
*
|
|
* GET /api/coach/about-me — full structured payload + pending questions
|
|
* PUT /api/coach/about-me — writes aboutMe (empty string clears) + allergies;
|
|
* conditions / coachFocus are omitted here and left untouched
|
|
*
|
|
* After a save the server derives up to 3 clarifying questions (AI
|
|
* when a provider + budget allow, deterministic hints otherwise); the
|
|
* panel surfaces them in a quiet callout and the Coach composer
|
|
* renders them as tappable chips.
|
|
*
|
|
* Plain text only — every value renders exclusively through inputs and
|
|
* React text children; no markdown anywhere.
|
|
*/
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Loader2,
|
|
MessageCircleQuestion,
|
|
NotebookPen,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Label } from "@/components/ui/label";
|
|
import { SettingsCard } from "@/components/settings/settings-card";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
|
|
|
const FALLBACK_MAX_CHARS = 4000;
|
|
const FALLBACK_FIELD_MAX_CHARS = 500;
|
|
|
|
interface AboutMeData {
|
|
aboutMe: string | null;
|
|
conditions: string | null;
|
|
allergies: string | null;
|
|
coachFocus: string | null;
|
|
pendingQuestions: string[];
|
|
updatedAt: string | null;
|
|
maxChars: number;
|
|
fieldMaxChars: number;
|
|
}
|
|
|
|
type StructuredKey = "allergies";
|
|
|
|
async function fetchAboutMe(): Promise<AboutMeData> {
|
|
return apiGet<AboutMeData>("/api/coach/about-me");
|
|
}
|
|
|
|
const FIELD_CLASSES =
|
|
"border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none";
|
|
|
|
export function AboutMeSection({
|
|
isAuthenticated,
|
|
}: {
|
|
isAuthenticated: boolean;
|
|
}) {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [drafts, setDrafts] = useState<{
|
|
aboutMe: string | null;
|
|
allergies: string | null;
|
|
}>({ aboutMe: null, allergies: null });
|
|
|
|
const query = useQuery({
|
|
queryKey: queryKeys.coachAboutMe(),
|
|
queryFn: fetchAboutMe,
|
|
enabled: isAuthenticated,
|
|
});
|
|
|
|
const saved = {
|
|
aboutMe: query.data?.aboutMe ?? "",
|
|
allergies: query.data?.allergies ?? "",
|
|
};
|
|
const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
|
|
const fieldMaxChars = query.data?.fieldMaxChars ?? FALLBACK_FIELD_MAX_CHARS;
|
|
const pendingQuestions = query.data?.pendingQuestions ?? [];
|
|
|
|
const value = {
|
|
aboutMe: drafts.aboutMe ?? saved.aboutMe,
|
|
allergies: drafts.allergies ?? saved.allergies,
|
|
};
|
|
const dirty =
|
|
value.aboutMe !== saved.aboutMe || value.allergies !== saved.allergies;
|
|
const hasAnyContent = saved.aboutMe.length > 0 || saved.allergies.length > 0;
|
|
|
|
// Quiet completeness meter: one segment per answered field. Reads
|
|
// the SAVED state (not the draft) so it only moves on persistence.
|
|
const answered = [saved.allergies, saved.aboutMe].filter(
|
|
(v) => v.length > 0,
|
|
).length;
|
|
|
|
const save = useMutation({
|
|
mutationKey: queryKeys.coachAboutMe(),
|
|
// Send only the two fields this panel owns. `conditions` / `coachFocus`
|
|
// are omitted so the server preserves them (they are edited under Anamnese).
|
|
mutationFn: async (input: { aboutMe: string; allergies: string }) => {
|
|
return apiPut<AboutMeData>("/api/coach/about-me", input);
|
|
},
|
|
onSuccess: (next) => {
|
|
const cleared = !next.aboutMe && !next.allergies;
|
|
toast.success(
|
|
cleared
|
|
? t("settings.ai.aboutMe.clearedToast")
|
|
: t("settings.ai.aboutMe.savedToast"),
|
|
);
|
|
setDrafts({ aboutMe: null, allergies: null });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.coachAboutMeQuestions(),
|
|
});
|
|
},
|
|
onError: () => {
|
|
toast.error(t("settings.ai.aboutMe.saveError"));
|
|
},
|
|
});
|
|
|
|
const disabled = !isAuthenticated || query.isLoading || save.isPending;
|
|
|
|
function structuredField(
|
|
key: StructuredKey,
|
|
label: string,
|
|
placeholder: string,
|
|
) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor={`settings-about-me-${key}`}>{label}</Label>
|
|
<textarea
|
|
id={`settings-about-me-${key}`}
|
|
data-testid={`settings-about-me-${key}`}
|
|
className={cn(FIELD_CLASSES, "min-h-16 resize-y")}
|
|
rows={2}
|
|
value={value[key]}
|
|
maxLength={fieldMaxChars}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
onChange={(e) => setDrafts((d) => ({ ...d, [key]: e.target.value }))}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SettingsCard
|
|
as="section"
|
|
aria-labelledby="settings-ai-about-me-title"
|
|
data-testid="settings-about-me-card"
|
|
className="space-y-5"
|
|
>
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<SettingsCardHeader
|
|
icon={NotebookPen}
|
|
title={t("settings.ai.aboutMe.title")}
|
|
titleId="settings-ai-about-me-title"
|
|
description={t("settings.ai.aboutMe.description")}
|
|
/>
|
|
{/* Completeness — two quiet segments, no percentage shouting. */}
|
|
<div
|
|
className="flex items-center gap-1.5"
|
|
aria-label={t("settings.ai.aboutMe.completeness", {
|
|
answered,
|
|
total: 2,
|
|
})}
|
|
title={t("settings.ai.aboutMe.completeness", {
|
|
answered,
|
|
total: 2,
|
|
})}
|
|
>
|
|
{[0, 1].map((i) => (
|
|
<span
|
|
key={i}
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"h-1 w-5 rounded-full transition-colors",
|
|
i < answered ? "bg-dracula-purple" : "bg-border",
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pl-7">
|
|
{structuredField(
|
|
"allergies",
|
|
t("settings.ai.aboutMe.allergiesLabel"),
|
|
t("settings.ai.aboutMe.allergiesPlaceholder"),
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1.5 pl-7">
|
|
<Label htmlFor="settings-about-me-freetext">
|
|
{t("settings.ai.aboutMe.freeTextLabel")}
|
|
</Label>
|
|
<textarea
|
|
id="settings-about-me-freetext"
|
|
data-testid="settings-about-me-textarea"
|
|
className={cn(FIELD_CLASSES, "min-h-36 resize-y")}
|
|
value={value.aboutMe}
|
|
maxLength={maxChars}
|
|
placeholder={t("settings.ai.aboutMe.placeholder")}
|
|
disabled={disabled}
|
|
onChange={(e) =>
|
|
setDrafts((d) => ({ ...d, aboutMe: e.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3 pl-7">
|
|
<p
|
|
data-testid="settings-about-me-count"
|
|
className="text-muted-foreground text-xs tabular-nums"
|
|
>
|
|
{t("settings.ai.aboutMe.charCount", {
|
|
used: value.aboutMe.length,
|
|
max: maxChars,
|
|
})}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
{hasAnyContent && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="min-h-11 sm:min-h-9"
|
|
data-testid="settings-about-me-clear"
|
|
disabled={!isAuthenticated || save.isPending}
|
|
onClick={() => save.mutate({ aboutMe: "", allergies: "" })}
|
|
>
|
|
<Trash2 className="size-4" aria-hidden />
|
|
{t("settings.ai.aboutMe.clear")}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="min-h-11 sm:min-h-9"
|
|
data-testid="settings-about-me-save"
|
|
disabled={!isAuthenticated || save.isPending || !dirty}
|
|
onClick={() => save.mutate(value)}
|
|
>
|
|
{save.isPending && (
|
|
<Loader2
|
|
className="size-4 animate-spin motion-reduce:animate-none"
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
{t("settings.ai.aboutMe.save")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{pendingQuestions.length > 0 && (
|
|
<div
|
|
data-testid="settings-about-me-questions"
|
|
className="border-dracula-purple/30 bg-dracula-purple/5 rounded-lg border p-4"
|
|
>
|
|
<p className="flex items-center gap-2 text-sm font-medium">
|
|
<MessageCircleQuestion
|
|
className="text-dracula-purple size-4 shrink-0"
|
|
aria-hidden="true"
|
|
/>
|
|
{t("settings.ai.aboutMe.questionsTitle")}
|
|
</p>
|
|
<ul className="text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm">
|
|
{pendingQuestions.map((q) => (
|
|
<li key={q}>{q}</li>
|
|
))}
|
|
</ul>
|
|
<p className="mt-3 text-xs">
|
|
<Link
|
|
href="/coach"
|
|
className="text-dracula-purple underline-offset-4 hover:underline"
|
|
>
|
|
{t("settings.ai.aboutMe.questionsOpenCoach")}
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{query.isError && (
|
|
<p
|
|
role="status"
|
|
aria-live="polite"
|
|
className="text-destructive text-sm"
|
|
>
|
|
{t("settings.ai.aboutMe.loadError")}
|
|
</p>
|
|
)}
|
|
|
|
<p className="text-muted-foreground border-border border-t pt-3 pl-7 text-xs">
|
|
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
|
|
</p>
|
|
|
|
{/* v1.25.12 — cross-link to the Anamnese (medical-history) home, where the
|
|
chronic conditions the Coach watches now live alongside allergies +
|
|
family history as one coherent medical history. */}
|
|
<p className="text-muted-foreground pl-7 text-xs">
|
|
{t("settings.ai.aboutMe.recordsLink")}{" "}
|
|
<Link
|
|
href="/settings/anamnesis"
|
|
className="text-dracula-purple underline-offset-4 hover:underline"
|
|
>
|
|
{t("settings.ai.aboutMe.recordsLinkCta")}
|
|
</Link>
|
|
</p>
|
|
</SettingsCard>
|
|
);
|
|
}
|