Files
PTSRaiAgent/web/src/screens/Flow.tsx
T
Pavel Eliseev a4ee3d25f0
CI / ruff · pytest (pull_request) Successful in 2m28s
fix(web): allow returning home from questionnaires
Keep the shared dialog and submitted answers while leaving the focus screen. Remove the history trap and wire the text-step home action.

Refs #85
2026-10-04 16:14:37 +03:00

731 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { Catalog, DialogButton } from "../api/types";
import { AppShell, Notice, TopBar } from "../components/AppShell";
import { HotlineCard, telHref, useHelp } from "../components/HelpSheet";
import { useToast } from "../components/Toast";
import { Icon, type IconName } from "../components/Icon";
import { useCatalog } from "../content/reference";
import { ru } from "../content/ru";
import { useDialog } from "../dialog/DialogContext";
import { classify, lines, type Step, type Survey } from "../dialog/step";
/*
* Focus screen: renders the engine's current step as one screen of docs/mock — consent (A4), age (A5),
* onboarding and scales (B1–B5, G4), daily check-in (D1–D6), medication (E1–E3, E5), crisis (G2), plan (G3),
* versioned questionnaires and the diary (stage 1 / stage 2: C-SSRS, PHQ, TSQ, PCL-5, RUS-AUDIT, дневник).
*/
const PHONE_RE = /(\+7[\d\s()-]{9,}\d|\b112\b|\b10[23]\b)/g;
function RichText({ text }: { text: string }) {
return (
<>
{text.split(PHONE_RE).map((part, i) =>
i % 2 === 1 ? (
<a key={i} href={telHref(part)} className="tel">
{part}
</a>
) : (
<Fragment key={i}>{part}</Fragment>
),
)}
</>
);
}
function Body({ texts }: { texts: string[] }) {
if (!texts.length) return null;
return (
<div className="body-text">
{texts.map((t, i) => {
const ls = lines(t);
return (
<div key={i} className="body-text__block">
{ls.map((l, j) =>
l.list ? (
<ul key={j} className="bullets">
<li>
<RichText text={l.text} />
</li>
</ul>
) : (
<p key={j}>
<RichText text={l.text} />
</p>
),
)}
</div>
);
})}
</div>
);
}
function Progress({ n, total, adaptive = false }: { n: number; total: number; adaptive?: boolean }) {
// Adaptive forms (C-SSRS, alcohol, diary) skip items by earlier answers: «из N» would be wrong, so no bar.
if (adaptive)
return (
<div className="progress">
<span className="progress__label">{ru.flow.questionN(n)}</span>
</div>
);
return (
<div className="progress" role="progressbar" aria-valuemin={1} aria-valuemax={total} aria-valuenow={n}>
<span className="progress__bar">
<span style={{ width: `${(n / total) * 100}%` }} />
</span>
<span className="progress__label">{ru.flow.question(n, total)}</span>
</div>
);
}
// RUS-AUDIT item 2: the standard-drink table of the approved form (no calculator — config/scales/rus_audit.yaml).
function DrinksTable({ table }: { table: NonNullable<Catalog["standard_drinks"]> }) {
return (
<section className="drinks" aria-label={ru.flow.drinks}>
<h2 className="h3">{ru.flow.drinks}</h2>
<p className="muted small">{ru.flow.drinksLead}</p>
<div className="drinks__scroll">
<table>
<thead>
<tr>
<th scope="col">СП</th>
{table.header.map((h) => (
<th key={h} scope="col">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{table.rows.map(([label, cells]) => (
<tr key={label}>
<th scope="row">{label.replace(/\s*СП$/, "")}</th>
{cells.map((c, i) => (
<td key={i}>{c}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="muted small">
{/* The form name stays hidden while answering: only the definition of a standard drink is shown. */}
{table.title.replace(/\s*Таблица бланка RUS-AUDIT:?/, "")} {table.note}
</p>
</section>
);
}
function Head({ title, lead, icon }: { title: string; lead: string[]; icon?: IconName }) {
return (
<header className="head">
{icon && (
<span className="tile-icon tile-icon--blue">
<Icon name={icon} size={26} />
</span>
)}
{title && <h1 className="h1">{title}</h1>}
<Body texts={lead} />
</header>
);
}
function Choice({
options,
busy,
onSubmit,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
}) {
const [picked, setPicked] = useState<DialogButton | null>(null);
return (
<>
<div className="options" role="radiogroup">
{options.map((b) => {
const on = picked?.data === b.data;
return (
<button
key={b.data}
type="button"
role="radio"
aria-checked={on}
className={on ? "option is-on" : "option"}
onClick={() => setPicked(b)}
>
<span className="option__mark">{on && <Icon name="check" size={16} />}</span>
<span className="option__label">{b.label}</span>
</button>
);
})}
</div>
<button
type="button"
className="btn btn--primary"
disabled={!picked || busy}
onClick={() => picked && onSubmit(picked)}
>
{ru.common.next}
</button>
</>
);
}
function NumberScale({
options,
busy,
onSubmit,
anchors,
}: {
options: DialogButton[];
busy: boolean;
onSubmit: (b: DialogButton) => void;
anchors?: string[];
}) {
const [picked, setPicked] = useState<DialogButton | null>(null);
return (
<>
<div className="numscale">
<span className="numscale__value" aria-live="polite">
{picked ? picked.label : "–"}
</span>
<div className="numscale__row" role="radiogroup" style={{ "--n": options.length } as CSSProperties}>
{options.map((b) => (
<button
key={b.data}
type="button"
role="radio"
aria-checked={picked?.data === b.data}
className={picked?.data === b.data ? "num is-on" : "num"}
onClick={() => setPicked(b)}
>
{b.label}
</button>
))}
</div>
{anchors && anchors.length === 2 && (
<div className="numscale__ends">
<span>{anchors[0]}</span>
<span>{anchors[1]}</span>
</div>
)}
</div>
<button
type="button"
className="btn btn--primary"
disabled={!picked || busy}
onClick={() => picked && onSubmit(picked)}
>
{ru.common.next}
</button>
</>
);
}
// Sent when a free-text step is submitted empty: a comment is never required (app/dialog/flows/meds.py).
const NO_COMMENT: DialogButton = { label: ru.flow.sendEmpty, data: "text:skip" };
function FreeText({
skip,
busy,
onSend,
onSkip,
onExit,
}: {
skip?: DialogButton;
busy: boolean;
onSend: (text: string) => void;
onSkip: (b: DialogButton) => void;
onExit: () => void;
}) {
const [text, setText] = useState("");
return (
<>
<div className="textarea">
<label htmlFor="free" className="visually-hidden">
{ru.messages.inputLabel}
</label>
<textarea
id="free"
rows={5}
maxLength={500}
value={text}
placeholder={ru.flow.textPlaceholder}
onChange={(e) => setText(e.target.value)}
/>
<span className="textarea__count">{text.length}/500</span>
</div>
<div className="btn-row">
{skip ? (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => onSkip(skip)}>
{skip.label}
</button>
) : (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={onExit}>
{ru.flow.exit}
</button>
)}
<button
type="button"
className="btn btn--primary"
disabled={busy}
onClick={() => (text.trim() ? onSend(text.trim()) : onSkip(skip ?? NO_COMMENT))}
>
{text.trim() ? ru.common.send : ru.flow.sendEmpty}
</button>
</div>
</>
);
}
const NUMERIC = {
hours: { label: ru.flow.hoursLabel, unit: ru.flow.hoursUnit, hint: ru.flow.hoursHint, max: 24 },
portions: { label: ru.flow.portionsLabel, unit: ru.flow.portionsUnit, hint: ru.flow.portionsHint, max: 50 },
};
function HoursInput({
busy,
onSend,
kind = "hours",
}: {
busy: boolean;
onSend: (text: string) => void;
kind?: keyof typeof NUMERIC;
}) {
const f = NUMERIC[kind];
const [v, setV] = useState("");
const n = Number(v.replace(",", "."));
const ok = v.trim() !== "" && Number.isFinite(n) && n >= 0 && n <= f.max;
return (
<form
className="hours"
onSubmit={(e) => {
e.preventDefault();
if (ok) onSend(v.trim());
}}
>
<label htmlFor="hours" className="hours__label">
{f.label}
</label>
<div className="hours__field">
<input
id="hours"
inputMode="decimal"
autoComplete="off"
value={v}
onChange={(e) => setV(e.target.value.replace(/[^\d.,]/g, "").slice(0, 4))}
aria-describedby="hours-hint"
/>
<span aria-hidden="true">{f.unit}</span>
</div>
<p id="hours-hint" className="muted small">
{f.hint}
</p>
<button type="submit" className="btn btn--primary" disabled={!ok || busy}>
{ru.common.next}
</button>
</form>
);
}
// No form name or section above the question: the person should not see what a questionnaire screens for while
// answering it. Names and results appear only after it is finished (Results screen).
function SurveyLead({ step, catalog }: { step: Survey; catalog: Catalog | null }) {
return (
<>
<header className="head survey">
{step.instruction.length > 0 && (
<div className="survey__instr">
<Body texts={step.instruction} />
</div>
)}
<h1 className="h1 survey__q">{step.title}</h1>
{step.footnotes.map((f) => (
<p key={f} className="muted small">
{f}
</p>
))}
</header>
{step.itemId === "rus_audit_2" && catalog?.standard_drinks && <DrinksTable table={catalog.standard_drinks} />}
</>
);
}
function SurveyAct({
step,
stepKey,
busy,
press,
say,
onPause,
onHome,
}: {
step: Survey;
stepKey: string;
busy: boolean;
press: (b: DialogButton) => void;
say: (text: string) => void;
onPause: (b: DialogButton) => void;
onHome: () => void;
}) {
let answer: ReactNode;
switch (step.input) {
case "number":
answer = <NumberScale key={stepKey} options={step.options} busy={busy} onSubmit={press} anchors={step.anchors} />;
break;
case "hours":
case "portions":
answer = <HoursInput key={stepKey} busy={busy} onSend={say} kind={step.input} />;
break;
case "text":
answer = (
<FreeText key={stepKey} skip={step.skip} busy={busy} onSend={say} onSkip={press} onExit={onHome} />
);
break;
default:
answer = (
<>
<Choice key={stepKey} options={step.options} busy={busy} onSubmit={press} />
{step.skip && (
<button type="button" className="btn btn--ghost" disabled={busy} onClick={() => press(step.skip!)}>
{step.skip.label}
</button>
)}
</>
);
}
return (
<>
{answer}
<div className="survey__aux">
{step.pause && (
<button type="button" className="btn btn--soft btn--auto" disabled={busy} onClick={() => onPause(step.pause!)}>
<Icon name="pause" size={18} />
{step.pause.label}
</button>
)}
{step.help && (
<button type="button" className="linkbtn linkbtn--danger" disabled={busy} onClick={() => press(step.help!)}>
<Icon name="alert" size={18} />
{step.help.label}
</button>
)}
</div>
</>
);
}
function MedActions({ options, busy, onPress }: { options: DialogButton[]; busy: boolean; onPress: (b: DialogButton) => void }) {
const style = (b: DialogButton) =>
b.data.startsWith("med:taken")
? { cls: "act act--teal", icon: "check" as IconName }
: b.data.startsWith("med:side_effects")
? { cls: "act act--red", icon: "alert" as IconName }
: { cls: "act", icon: "clock" as IconName };
return (
<div className="acts">
{options.map((b) => {
const s = style(b);
return (
<button key={b.data} type="button" className={s.cls} disabled={busy} onClick={() => onPress(b)}>
<span className="act__icon">
<Icon name={s.icon} size={20} />
</span>
{b.label}
</button>
);
})}
</div>
);
}
function Breathing() {
return (
<section className="breath" aria-label={ru.flow.breathe}>
<Icon name="lungs" size={28} />
<div className="breath__text">
<p className="breath__title">{ru.flow.breathe}</p>
<div className="breath__viz">
<span>{ru.flow.breatheIn}</span>
<span className="breath__ring" aria-hidden="true" />
<span>{ru.flow.breatheOut}</span>
</div>
</div>
</section>
);
}
// The engine's closing note: its first paragraph is the result headline (config/texts/results.yaml), the rest
// explains it. Results stay in «Мои результаты» with the date.
function Done({ notes, onHome, onResults }: { notes: string[]; onHome: () => void; onResults: () => void }) {
const [head = ru.flow.doneTitle, ...rest] = notes
.join("\n\n")
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean);
return (
<div className="stack narrow done">
<span className="done__badge" aria-hidden="true">
<Icon name="check" size={40} />
</span>
<h1 className="h1 center">{head}</h1>
{rest.length > 0 && (
<div className="panel">
<Body texts={rest} />
</div>
)}
<button type="button" className="btn btn--primary" onClick={onHome}>
{ru.common.toToday}
</button>
<button type="button" className="btn btn--soft" onClick={onResults}>
{ru.today.results}
</button>
</div>
);
}
const SHORT_PARAGRAPHS = 3;
export function Flow() {
const navigate = useNavigate();
const toast = useToast();
const dialog = useDialog();
const { resources } = useHelp();
const catalog = useCatalog();
const step: Step | null = useMemo(() => classify(dialog.pending), [dialog.pending]);
const stepKey = useMemo(() => dialog.pending.map((r) => r.text).join("|"), [dialog.pending]);
const home = () => navigate("/today");
// First visit after sign-in: the engine opens the dialog (consent → age → onboarding), as /start in Telegram.
useEffect(() => {
if (!dialog.started && !dialog.busy) void dialog.start();
}, []);
useEffect(() => {
dialog.markRead();
}, [dialog.pending]);
// A short confirmation becomes a toast in the top-right corner and the person goes straight home. Only a long
// result (the conclusion of the first questionnaire) keeps its own screen.
const toasted = useRef("");
useEffect(() => {
if (!step || (step.kind !== "idle" && step.kind !== "ack")) return;
const texts = step.kind === "ack" ? step.text : step.notes;
const paragraphs = texts.join("\n\n").split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
if (step.kind === "idle" && paragraphs.length > SHORT_PARAGRAPHS) return;
if (paragraphs.length && toasted.current !== stepKey) {
toasted.current = stepKey;
toast(paragraphs.join(" "));
}
navigate("/today", { replace: true });
}, [step, stepKey, navigate, toast]);
const press = (b: DialogButton) => void dialog.send({ type: "button", button: b.data }, b.label);
// «Продолжить позже»: answers are already saved on the server — pause and go straight home, no confirmation.
const pause = (b: DialogButton) =>
void dialog.send({ type: "button", button: b.data }, b.label).then((ok) => ok && navigate("/today", { replace: true }));
const say = (text: string) => void dialog.send({ type: "text", text }, text);
let content: ReactNode;
if (!step) {
content = dialog.error ? (
<div className="stack narrow">
<Notice tone="danger">{ru.common.networkError}</Notice>
<button type="button" className="btn btn--primary" onClick={() => void dialog.start()}>
{ru.common.retry}
</button>
</div>
) : (
<div className="loader" aria-label="Загрузка" />
);
} else if (step.kind === "ack") {
content = <div className="loader" aria-label="Загрузка" />;
} else if (step.kind === "idle") {
content = <Done notes={step.notes} onHome={home} onResults={() => navigate("/results")} />;
} else if (step.kind === "notice") {
content = (
<div className="stack narrow done">
<span className="tile-icon tile-icon--blue tile-icon--xl center-self" aria-hidden="true">
<Icon name={step.help ? "shield" : "pause"} size={32} />
</span>
<h1 className="h1 center">{ru.flow.noticeTitle}</h1>
<div className="panel">
<Body texts={step.text} />
</div>
{step.primary && (
<button type="button" className="btn btn--primary" disabled={dialog.busy} onClick={() => press(step.primary!)}>
{step.primary.label}
</button>
)}
{step.help && (
<button type="button" className="btn btn--danger" disabled={dialog.busy} onClick={() => press(step.help!)}>
{step.help.label}
</button>
)}
<button type="button" className="btn btn--soft" onClick={home}>
{ru.common.toToday}
</button>
</div>
);
} else {
const item = step.kind === "survey" ? catalog?.items[step.itemId] : undefined;
const progress =
step.kind === "choice" && step.progress ? (
<Progress {...step.progress} />
) : item ? (
<Progress n={item.index} total={item.total} adaptive={item.adaptive} />
) : null;
const top = progress ? <TopBar>{progress}</TopBar> : null;
// Phone: one column. Desktop: question and context on the left, answers and actions on the right.
let lead: ReactNode;
let act: ReactNode;
switch (step.kind) {
case "consent":
lead = (
<>
<Head title={ru.flow.consentTitle} lead={[]} icon="shield" />
<div className="panel panel--scroll">
<Body texts={[...step.lead, step.title].filter(Boolean)} />
</div>
</>
);
act = (
<div className="btn-row btn-row--stack-desk">
{step.buttons.map((b, i) => (
<button
key={b.data}
type="button"
className={i === 0 ? "btn btn--primary" : "btn btn--outline"}
disabled={dialog.busy}
onClick={() => press(b)}
>
{b.label}
</button>
))}
</div>
);
break;
case "number":
lead = <Head title={step.title} lead={step.lead} icon="smile" />;
act = <NumberScale key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
break;
case "actions":
lead = (
<div className="reminder">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="pill" size={34} />
</span>
<h1 className="h1 center">{step.title || ru.meds.reminderTitle}</h1>
<Body texts={step.lead} />
</div>
);
act = <MedActions options={step.options} busy={dialog.busy} onPress={press} />;
break;
case "text":
lead = <Head title={step.title} lead={step.lead} icon="edit" />;
act = (
<FreeText key={stepKey} skip={step.skip} busy={dialog.busy} onSend={say} onSkip={press} onExit={home} />
);
break;
case "crisis":
lead = (
<>
<header className="head">
<span className="tile-icon tile-icon--teal tile-icon--xl">
<Icon name="heart" size={32} />
</span>
<h1 className="h1">{ru.flow.supportTitle}</h1>
</header>
<div className="panel">
<Body texts={[step.text]} />
</div>
</>
);
act = (
<>
<div className="hotlines">
{resources.map((r) => (
<HotlineCard key={r.phone} r={r} />
))}
</div>
<Breathing />
{step.buttons.map((b) => (
<button key={b.data} type="button" className="btn btn--outline" disabled={dialog.busy} onClick={() => press(b)}>
{b.label}
</button>
))}
<button
type="button"
className="row-card"
disabled={dialog.busy}
onClick={() => press({ label: ru.flow.openPlan, data: "safety_plan" })}
>
<Icon name="shield" />
<span className="row-card__text">
<span className="row-card__title">{ru.flow.openPlan}</span>
</span>
<Icon name="chevronRight" />
</button>
</>
);
break;
case "plan":
lead = <Head title={step.title || ru.flow.planTitle} lead={step.lead} icon="shield" />;
act = (
<>
<ul className="plan">
{step.lines.map((l) => (
<li key={l} className="plan__item">
<Icon name="shield" size={20} />
<span>{l}</span>
</li>
))}
</ul>
<button type="button" className="btn btn--primary" onClick={home}>
{ru.common.toToday}
</button>
</>
);
break;
case "survey":
lead = <SurveyLead step={step} catalog={catalog} />;
act = <SurveyAct step={step} stepKey={stepKey} busy={dialog.busy} press={press} say={say} onPause={pause} onHome={home} />;
break;
case "choice":
lead = <Head title={step.title} lead={step.lead} />;
act = <Choice key={stepKey} options={step.options} busy={dialog.busy} onSubmit={press} />;
break;
}
content = (
<div className="focus" key={stepKey}>
{top}
<div className={`split split--${step.kind}${step.kind === "survey" ? ` split--in-${step.input}` : ""}`}>
<div className="split__lead">{lead}</div>
<div className="split__act">
{act}
{dialog.error && <Notice tone="danger">{ru.common.networkError}</Notice>}
</div>
</div>
</div>
);
}
return (
<AppShell focus bare sos={step?.kind !== "crisis"}>
<nav className="flow-nav" aria-label={ru.flow.home}>
{/* Leaving does not send an answer or clear pending: Today can reopen this same question.
Wait for an in-flight answer before leaving so its result has reached the shared dialog. */}
<button type="button" className="btn btn--ghost btn--auto" disabled={dialog.busy} onClick={home}>
<Icon name="home" size={20} />
{ru.flow.home}
</button>
</nav>
{content}
</AppShell>
);
}