mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
The regenerate button aborted its inline POST after a fixed 45 s, so on a slow local model every regenerate was cut while the server still had minutes left on the person's setting. It now uses clientAbortMs over the route's sequential model calls, never less per call than the briefing's own 180 s default, because the published value cannot say whether the person set it. A save of the about-me self-context derives the clarifying questions inline and ran under the 15 s request default, which reported a failed save while the server went on to commit it. All six save sites now take their abort from the same setting. Every other browser request that waits on a model either has no client timeout or only queues the work. Refs #1267
235 lines
8.6 KiB
TypeScript
235 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* #159 — the "About me" free-text note, at home in the Anamnese
|
|
* (medical-history) section.
|
|
*
|
|
* v1.15.20 introduced the note under Settings → AI, v1.18.1 moved it to the
|
|
* account profile, v1.25.12 moved the chronic conditions + Coach focus out of
|
|
* it into the Anamnese. This completes the move: the note itself is personal
|
|
* medical context the Coach and the daily briefing read, so it lives with
|
|
* conditions, allergies and family history as one coherent medical history.
|
|
* The card header (title/description) is rendered by `anamnesis-section.tsx`,
|
|
* matching the neighbouring managers.
|
|
*
|
|
* Data path is unchanged: the note is part of the self-context payload
|
|
* (`GET`/`PUT /api/coach/about-me`), read and written through the same
|
|
* `queryKeys.coachAboutMe()` query- and mutation-key the rest of the app
|
|
* uses. The PUT sends only the field this editor owns (plus the base token),
|
|
* so conditions / focus / allergies stay untouched by contract.
|
|
*
|
|
* The "Clear" control writes `aboutMe: ""` and NOTHING else — the allergies
|
|
* field is omitted from the payload so the stored allergy line survives (the
|
|
* v1.32-era scoped-clear lesson; see `about-me-clear-scope.test.tsx`).
|
|
*
|
|
* 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, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { showErrorToast } from "@/lib/errors/error-toast";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
|
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
|
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
|
import { Label } from "@/components/ui/label";
|
|
import { ConfirmButton } from "@/components/ui/confirm-button";
|
|
import {
|
|
useAboutMeSaveTimeoutMs,
|
|
useAiCapability,
|
|
} from "@/hooks/use-ai-capability";
|
|
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";
|
|
import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
|
|
|
|
const FALLBACK_MAX_CHARS = 4000;
|
|
|
|
interface AboutMeData {
|
|
aboutMe: string | null;
|
|
conditions: string | null;
|
|
allergies: string | null;
|
|
coachFocus: string | null;
|
|
pendingQuestions: string[];
|
|
updatedAt: string | null;
|
|
maxChars: number;
|
|
fieldMaxChars: number;
|
|
}
|
|
|
|
const FIELD_CLASSES =
|
|
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
|
|
|
|
export function AboutMeNoteManager() {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const coach = useAiCapability("coach");
|
|
const [draft, setDraft] = useState<string | null>(null);
|
|
|
|
const query = useQuery({
|
|
queryKey: queryKeys.coachAboutMe(),
|
|
queryFn: () => apiGet<AboutMeData>("/api/coach/about-me"),
|
|
});
|
|
|
|
const savedNote = query.data?.aboutMe ?? "";
|
|
const maxChars = query.data?.maxChars ?? FALLBACK_MAX_CHARS;
|
|
const pendingQuestions = query.data?.pendingQuestions ?? [];
|
|
|
|
const value = draft ?? savedNote;
|
|
const dirty = value !== savedNote;
|
|
// The clear control only ever touches the note, so it only appears when
|
|
// there is a note to clear.
|
|
const hasNote = savedNote.length > 0;
|
|
|
|
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
|
const save = useMutation({
|
|
mutationKey: queryKeys.coachAboutMe(),
|
|
// Send only the note. `conditions` / `coachFocus` / `allergies` are
|
|
// omitted so the server preserves them (they are edited in their own
|
|
// cards). v1.32.21 (R5a) — echo the base token this edit was based on so
|
|
// a concurrent write to the same self-context 409s instead of silently
|
|
// overwriting the newer state.
|
|
mutationFn: async (input: { aboutMe: string }) => {
|
|
return apiPut<AboutMeData>(
|
|
"/api/coach/about-me",
|
|
withBaseToken(input, query.data?.updatedAt ?? undefined),
|
|
{ signal: AbortSignal.timeout(saveTimeoutMs) },
|
|
);
|
|
},
|
|
onSuccess: (next) => {
|
|
const cleared = !next.aboutMe;
|
|
toast.success(
|
|
cleared
|
|
? t("settings.ai.aboutMe.clearedToast")
|
|
: t("settings.ai.aboutMe.savedToast"),
|
|
);
|
|
setDraft(null);
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.coachAboutMeQuestions(),
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
// v1.32.21 — a 409 means the stored self-context advanced since
|
|
// this edit was based. Refetch so the token advances, KEEP the draft so
|
|
// the user can re-save, nudge gently.
|
|
if (isConflict(err)) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() });
|
|
toast.message(t("common.conflictReloaded"));
|
|
return;
|
|
}
|
|
showErrorToast(t("settings.ai.aboutMe.saveError"), { cause: err });
|
|
},
|
|
});
|
|
|
|
const disabled = query.isLoading || save.isPending;
|
|
|
|
return (
|
|
<div className="space-y-4" data-slot="about-me-note-manager">
|
|
<div className="space-y-1.5">
|
|
<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}
|
|
maxLength={maxChars}
|
|
placeholder={t("settings.ai.aboutMe.placeholder")}
|
|
disabled={disabled}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
/>
|
|
<p
|
|
data-testid="settings-about-me-count"
|
|
className="text-muted-foreground text-xs tabular-nums"
|
|
>
|
|
{t("settings.ai.aboutMe.charCount", {
|
|
used: value.length,
|
|
max: maxChars,
|
|
})}
|
|
</p>
|
|
{/* What the note is for, under the field it describes; the action row
|
|
stays the last thing in the card (design standards §12). */}
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
|
|
</p>
|
|
</div>
|
|
|
|
{pendingQuestions.length > 0 && (
|
|
<SettingsInfoTile
|
|
icon={MessageCircleQuestion}
|
|
tone="primary"
|
|
data-testid="settings-about-me-questions"
|
|
title={t("settings.ai.aboutMe.questionsTitle")}
|
|
>
|
|
<ul className="list-disc space-y-1 pl-6">
|
|
{pendingQuestions.map((q) => (
|
|
<li key={q}>{q}</li>
|
|
))}
|
|
</ul>
|
|
{/* Only while the Coach can open; without it the link would
|
|
bounce back to Insights. The questions stay readable. */}
|
|
{coach.available ? (
|
|
<p className="mt-3 text-xs">
|
|
<Link
|
|
href="/coach"
|
|
className="text-primary underline-offset-4 hover:underline"
|
|
>
|
|
{t("settings.ai.aboutMe.questionsOpenCoach")}
|
|
</Link>
|
|
</p>
|
|
) : null}
|
|
</SettingsInfoTile>
|
|
)}
|
|
|
|
{query.isError && (
|
|
<QueryErrorRow
|
|
error={query.error}
|
|
message={t("settings.ai.aboutMe.loadError")}
|
|
onRetry={() => query.refetch()}
|
|
/>
|
|
)}
|
|
|
|
<SettingsCardActions>
|
|
{hasNote && (
|
|
<ConfirmButton
|
|
slot="settings-about-me-clear"
|
|
destructive
|
|
label={t("settings.ai.aboutMe.clear")}
|
|
icon={<Trash2 className="size-4" aria-hidden />}
|
|
variant="outline"
|
|
size="sm"
|
|
className="min-h-11 sm:min-h-9"
|
|
title={t("settings.ai.aboutMe.clearTitle")}
|
|
body={t("settings.ai.aboutMe.clearBody")}
|
|
confirmLabel={t("settings.ai.aboutMe.clearConfirm")}
|
|
pending={save.isPending}
|
|
onConfirm={() => save.mutate({ aboutMe: "" })}
|
|
/>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="min-h-11 sm:min-h-9"
|
|
data-testid="settings-about-me-save"
|
|
disabled={save.isPending || !dirty}
|
|
onClick={() => save.mutate({ aboutMe: value })}
|
|
>
|
|
{save.isPending && (
|
|
<Loader2
|
|
className="size-4 animate-spin motion-reduce:animate-none"
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
{t("settings.ai.aboutMe.save")}
|
|
</Button>
|
|
</SettingsCardActions>
|
|
</div>
|
|
);
|
|
}
|