feat(web): screens from docs/mock — focus flow, today, meds, doctor, profile
- /flow renders the dialog engine's current step as a mock screen (consent,
age, onboarding and scales with progress, mood 0–10, free text, dose
reminder, crisis with hotlines and breathing, safety plan), so every
engine scenario gets the design without duplicating its logic
- today / meds / doctor / profile / messages tabs; navy sidebar and
emergency rail on desktop, emergency block and tab bar on phones
- API: GET /me/{token}/today (today's doses, week marks, next visit, demo
clock time); demo ЕМИАС sign-in seeds the persona's synthetic profile and
prescription from the dashboard fixtures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import type { DialogEvent, Reply } from "../api/types";
|
||||
import { useSession } from "../session/SessionContext";
|
||||
import { sessionStore, type ChatEntry, type DialogSnapshot } from "../session/store";
|
||||
|
||||
/*
|
||||
* One conversation with the dialog engine per signed-in patient, shared by every screen:
|
||||
* focus screens render `pending` (the latest unanswered batch), «Сообщения» renders `entries`.
|
||||
* The engine owns the logic (consent, onboarding, scales, check-ins, meds, crisis) — exactly as in Telegram.
|
||||
*/
|
||||
|
||||
const OUTBOX_POLL_MS = 6000;
|
||||
|
||||
let seq = 0;
|
||||
const newId = () => `${Date.now().toString(36)}-${(seq++).toString(36)}`;
|
||||
const asEntries = (replies: Reply[]): ChatEntry[] => replies.map((reply) => ({ id: newId(), from: "assistant", reply }));
|
||||
|
||||
interface DialogApi extends DialogSnapshot {
|
||||
busy: boolean;
|
||||
error: boolean;
|
||||
started: boolean;
|
||||
/** Sends an event; `echo` is what the patient «said» (button label / text) for the history. */
|
||||
send: (event: DialogEvent, echo?: string) => Promise<boolean>;
|
||||
start: () => Promise<boolean>;
|
||||
markRead: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<DialogApi | null>(null);
|
||||
const EMPTY: DialogSnapshot = { entries: [], pending: [], unread: 0 };
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const { session } = useSession();
|
||||
const token = session?.token ?? null;
|
||||
const [owner, setOwner] = useState(token);
|
||||
const [snap, setSnap] = useState<DialogSnapshot>(() => (token ? sessionStore.loadDialog(token) : EMPTY));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const tokenRef = useRef(token);
|
||||
tokenRef.current = token;
|
||||
|
||||
// Switching accounts: reset to that token's snapshot during render, so it is never saved under another token.
|
||||
if (owner !== token) {
|
||||
setOwner(token);
|
||||
setSnap(token ? sessionStore.loadDialog(token) : EMPTY);
|
||||
setError(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (token && owner === token) sessionStore.saveDialog(token, snap);
|
||||
}, [token, owner, snap]);
|
||||
|
||||
const send = useCallback(
|
||||
async (event: DialogEvent, echo?: string) => {
|
||||
const t = tokenRef.current;
|
||||
if (!t) return false;
|
||||
setBusy(true);
|
||||
setError(false);
|
||||
try {
|
||||
const replies = await api.sendEvent(t, event);
|
||||
setSnap((prev) => ({
|
||||
entries: [
|
||||
...prev.entries,
|
||||
...(echo ? [{ id: newId(), from: "patient" as const, text: echo }] : []),
|
||||
...asEntries(replies),
|
||||
],
|
||||
pending: replies,
|
||||
unread: 0,
|
||||
}));
|
||||
return true;
|
||||
} catch {
|
||||
setError(true);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const start = useCallback(() => send({ type: "start" }), [send]);
|
||||
const markRead = useCallback(() => setSnap((prev) => (prev.unread ? { ...prev, unread: 0 } : prev)), []);
|
||||
|
||||
// System-initiated messages (reminders, check-ins, scales) arrive through the outbox.
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const timer = window.setInterval(() => {
|
||||
api
|
||||
.outbox(token)
|
||||
.then((replies) => {
|
||||
if (!replies.length || tokenRef.current !== token) return;
|
||||
setSnap((prev) => ({
|
||||
entries: [...prev.entries, ...asEntries(replies)],
|
||||
pending: replies,
|
||||
unread: prev.unread + replies.length,
|
||||
}));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, OUTBOX_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [token]);
|
||||
|
||||
const value = useMemo<DialogApi>(
|
||||
() => ({ ...snap, busy, error, started: snap.entries.length > 0, send, start, markRead }),
|
||||
[snap, busy, error, send, start, markRead],
|
||||
);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useDialog(): DialogApi {
|
||||
const value = useContext(Ctx);
|
||||
if (!value) throw new Error("useDialog outside DialogProvider");
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { DialogButton, Reply } from "../api/types";
|
||||
|
||||
/*
|
||||
* Maps a batch of engine replies to a focus-screen layout (docs/mock). Pure function: the engine decides WHAT is
|
||||
* asked, this only decides HOW it looks. Unknown shapes fall back to a plain choice / text screen.
|
||||
*/
|
||||
|
||||
export type Step =
|
||||
| { kind: "idle"; notes: string[] } // engine is back at the menu: the flow is over
|
||||
| { kind: "consent"; lead: string[]; title: string; buttons: DialogButton[] }
|
||||
| { kind: "crisis"; text: string; buttons: DialogButton[] }
|
||||
| { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] }
|
||||
| { kind: "number"; lead: string[]; title: string; options: DialogButton[] }
|
||||
| { kind: "actions"; lead: string[]; title: string; options: DialogButton[] }
|
||||
| { kind: "choice"; lead: string[]; title: string; options: DialogButton[]; progress?: Progress }
|
||||
| { kind: "text"; lead: string[]; title: string; skip?: DialogButton };
|
||||
|
||||
export interface Progress {
|
||||
n: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// config/texts/dialog.yaml → idle.menu_prompt: the bare menu carries no message of its own.
|
||||
const MENU_PROMPT = "Чем могу помочь?";
|
||||
// config/texts/dialog.yaml → scale.question: "{n}/{total}. {text}"
|
||||
const SCALE_ITEM = /^(\d+)\/(\d+)\.\s+([\s\S]+)$/;
|
||||
|
||||
const isMenu = (r: Reply) => r.buttons.flat().some((b) => b.data.startsWith("menu:"));
|
||||
const flat = (r: Reply) => r.buttons.flat();
|
||||
const paragraphs = (text: string) =>
|
||||
text
|
||||
.split(/\n\s*\n/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/** Splits a reply into a title (the actual question) and lead paragraphs above it. */
|
||||
function titled(texts: string[]): { lead: string[]; title: string; progress?: Progress } {
|
||||
const parts = texts.flatMap(paragraphs);
|
||||
const last = parts.pop() ?? "";
|
||||
const m = SCALE_ITEM.exec(last);
|
||||
if (m) return { lead: parts, title: m[3].trim(), progress: { n: Number(m[1]), total: Number(m[2]) } };
|
||||
// A long last paragraph is body text, not a heading.
|
||||
if (last.length > 220) return { lead: [...parts, last], title: "" };
|
||||
return { lead: parts, title: last };
|
||||
}
|
||||
|
||||
export function classify(batch: Reply[]): Step | null {
|
||||
if (batch.length === 0) return null;
|
||||
|
||||
const crisis = batch.find((r) => r.kind === "crisis");
|
||||
if (crisis) return { kind: "crisis", text: crisis.text, buttons: batch.flatMap(flat) };
|
||||
|
||||
if (batch.some(isMenu)) {
|
||||
const notes = batch
|
||||
.filter((r) => !isMenu(r) || r.text.trim() !== MENU_PROMPT)
|
||||
.map((r) => r.text.trim())
|
||||
.filter((t) => t && t !== MENU_PROMPT);
|
||||
return { kind: "idle", notes };
|
||||
}
|
||||
|
||||
const plan = batch.find((r) => r.kind === "safety_plan");
|
||||
if (plan) {
|
||||
const [title, ...rest] = plan.text.split("\n").map((l) => l.trim());
|
||||
const lead = batch.filter((r) => r !== plan).map((r) => r.text);
|
||||
return {
|
||||
kind: "plan",
|
||||
lead,
|
||||
title,
|
||||
lines: rest.filter(Boolean).map((l) => l.replace(/^[•\-–]\s*/, "")),
|
||||
buttons: batch.flatMap(flat),
|
||||
};
|
||||
}
|
||||
|
||||
// The question is the last reply with buttons; everything before it is context.
|
||||
const qIndex = batch.map((r) => r.buttons.length > 0).lastIndexOf(true);
|
||||
const texts = batch.map((r) => r.text);
|
||||
if (qIndex === -1) {
|
||||
const { lead, title } = titled(texts);
|
||||
return { kind: "text", lead, title };
|
||||
}
|
||||
const options = flat(batch[qIndex]);
|
||||
const head = titled(texts.slice(0, qIndex + 1));
|
||||
const tail = texts.slice(qIndex + 1); // rare: info after the question
|
||||
const lead = [...head.lead, ...tail];
|
||||
|
||||
if (options.some((b) => b.data === "consent:yes")) return { kind: "consent", lead, title: head.title, buttons: options };
|
||||
if (options.every((b) => b.data.endsWith(":skip")))
|
||||
return { kind: "text", lead, title: head.title, skip: options[0] };
|
||||
if (options.length >= 5 && options.every((b) => /^\d{1,2}$/.test(b.label.trim())))
|
||||
return { kind: "number", lead, title: head.title, options };
|
||||
if (options.some((b) => /^med:(taken|skip|side_effects):/.test(b.data)))
|
||||
return { kind: "actions", lead, title: head.title, options };
|
||||
return { kind: "choice", lead, title: head.title, options, progress: head.progress };
|
||||
}
|
||||
|
||||
/** Blocks of an engine text: «• item» lines become list items; wrapped plain lines are joined into paragraphs. */
|
||||
export function lines(text: string): { list: boolean; text: string }[] {
|
||||
const out: { list: boolean; text: string }[] = [];
|
||||
for (const raw of text.split("\n")) {
|
||||
const l = raw.trim();
|
||||
if (!l) continue;
|
||||
if (/^[•\-–]\s+/.test(l)) out.push({ list: true, text: l.replace(/^[•\-–]\s+/, "") });
|
||||
else if (out.length && !out[out.length - 1].list && !/[.!?:»)]$/.test(out[out.length - 1].text))
|
||||
out[out.length - 1] = { list: false, text: `${out[out.length - 1].text} ${l}` };
|
||||
else out.push({ list: false, text: l });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user