diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f68cdd7..91eef70 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -4,6 +4,7 @@ import type { CrisisResource, DemoPersona, DialogEvent, + Garden, Focus, MedsReference, Reply, @@ -69,6 +70,7 @@ export const api = { catalog: () => request("/instruments/catalog"), medsReference: () => request("/meds/reference"), today: (token: string) => request(`/me/${encodeURIComponent(token)}/today`), + garden: (token: string) => request(`/me/${encodeURIComponent(token)}/garden`), results: (token: string) => request(`/me/${encodeURIComponent(token)}/results`), sendEvent: async (token: string, event: DialogEvent) => (await post<{ replies: Reply[] }>(`/dialog/${encodeURIComponent(token)}/events`, event)).replies, diff --git a/web/src/api/types.ts b/web/src/api/types.ts index de5c3e5..c2e52e6 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -180,3 +180,16 @@ export interface MedsReference { string >; } + +/** GET /me/{token}/garden (#118): grows from activities, never shrinks, ignores answers. */ +export type GardenActivity = "meds" | "diary" | "questionnaire"; + +export interface Garden { + points: number; + steps: number; + title: string; + step_points: number; + points_in_step: number; + plants: { stage: number }[]; + today: { activity: GardenActivity; title: string; done: boolean; detail: string | null }[]; +} diff --git a/web/src/components/Garden.tsx b/web/src/components/Garden.tsx new file mode 100644 index 0000000..70785af --- /dev/null +++ b/web/src/components/Garden.tsx @@ -0,0 +1,181 @@ +import { useEffect, useRef, useState } from "react"; + +import { api } from "../api/client"; +import type { Garden as GardenData, GardenActivity } from "../api/types"; +import { ru } from "../content/ru"; +import { useDialog } from "../dialog/DialogContext"; +import { useSession } from "../session/SessionContext"; +import { Icon, type IconName } from "./Icon"; +import { useToast } from "./Toast"; + +/* + * «Ваш сад» (#119, slide 13): every useful activity grows the garden. Without pressure — it never wilts, does not + * depend on answers, nobody competes; there are no streaks, no «missed» marks and no red. The numbers come from the + * server (#118); this only draws them. + */ + +const FLOWERS = ["#d9604c", "#e9a23b", "#c96f9a", "#e07a3f", "#b9577a", "#d4a02a"]; +const SLOTS = [44, 92, 140, 188, 236, 284]; +const GROUND = 128; +const ICONS: Record = { meds: "pill", diary: "book", questionnaire: "clipboard" }; +const seenKey = (token: string) => `ptsr.garden.steps.${token}`; + +function Plant({ x, stage, color, fresh }: { x: number; stage: number; color: string; fresh: boolean }) { + if (stage <= 0) return ; + const h = [0, 18, 38, 54, 62][Math.min(stage, 4)]; + const top = GROUND - h; + return ( + + + + {stage >= 2 && } + {stage === 1 && } + {stage === 3 && } + {stage >= 4 && ( + + {[0, 72, 144, 216, 288].map((a) => ( + + ))} + + + )} + + ); +} + +export function Garden({ refreshKey }: { refreshKey?: unknown }) { + const { session } = useSession(); + const dialog = useDialog(); + const toast = useToast(); + const [garden, setGarden] = useState(null); + const [freshFrom, setFreshFrom] = useState(null); + const announced = useRef(false); + + useEffect(() => { + if (!session) return; + let alive = true; + api + .garden(session.token) + .then((g) => { + if (!alive) return; + setGarden(g); + const raw = window.localStorage.getItem(seenKey(session.token)); + const seen = raw === null ? null : Number(raw); + // No celebration right after a crisis: growth stays, the toast and the animation do not. + const recentCrisis = dialog.entries.slice(-12).some((e) => e.from === "assistant" && e.reply.kind === "crisis"); + if (seen !== null && g.steps > seen && !recentCrisis) { + setFreshFrom(seen); + if (!announced.current) toast(ru.garden.grew); + announced.current = true; + } + window.localStorage.setItem(seenKey(session.token), String(g.steps)); + }) + .catch(() => undefined); + return () => { + alive = false; + }; + }, [session, refreshKey]); + + if (!garden) return null; + const t = ru.garden; + const perPlant = 4; + const bloom = garden.plants.filter((p) => p.stage >= perPlant).length; + const growing = garden.plants.filter((p) => p.stage > 0 && p.stage < perPlant).length; + const share = garden.points_in_step / garden.step_points; + + return ( +
+
+

{t.eyebrow}

+

+ {t.lead} +

+
+ + + + + + + + + + + + {garden.plants.map((p, i) => { + // The plant that just grew (its stage covers steps after the last seen count) gets a gentle grow-in. + const fresh = + freshFrom !== null && garden.steps > freshFrom && Math.floor((garden.steps - 1) / perPlant) === i; + return ( + + ); + })} + +

{garden.title}

+
+
+ {t.next} + {t.of(garden.points_in_step, garden.step_points)} +
+
+ +
+
+ {garden.today.length > 0 && ( +
    + {garden.today.map((item) => ( +
  • + + + {item.title} + {item.detail && · {item.detail}} + + + {item.done && } + +
  • + ))} +
+ )} +

{t.noPressure}

+
+ ); +} diff --git a/web/src/content/ru.ts b/web/src/content/ru.ts index 73ece5c..29c0bdf 100644 --- a/web/src/content/ru.ts +++ b/web/src/content/ru.ts @@ -86,6 +86,17 @@ export const ru = { unavailable: "Первичный опрос сейчас отключён на сервере. Кабинет и подключение врача доступны без личных данных.", submitting: "Создаём профиль…", }, + garden: { + eyebrow: "Ваш сад", + lead: "Растёт вместе с вами", + next: "Новый росток", + of: (a: number, b: number) => `${a} из ${b}`, + today: "Сегодня", + noPressure: "Пропуск — не страшно, сад не вянет", + grew: "Сад подрос — появился новый росток", + label: (title: string, bloom: number, growing: number) => + `${title}. Цветов: ${bloom}, растёт: ${growing}.`, + }, today: { title: "Сегодня", takeMed: "Принять лекарство", diff --git a/web/src/screens/Today.tsx b/web/src/screens/Today.tsx index f885668..029de2b 100644 --- a/web/src/screens/Today.tsx +++ b/web/src/screens/Today.tsx @@ -2,6 +2,7 @@ import { useNavigate } from "react-router-dom"; import type { TodayDose, TodayPrescription } from "../api/types"; import { AppShell, Notice } from "../components/AppShell"; +import { Garden } from "../components/Garden"; import { Icon, type IconName } from "../components/Icon"; import { useCatalog } from "../content/reference"; import { ru } from "../content/ru"; @@ -253,6 +254,7 @@ export function Today() { )} +
diff --git a/web/src/theme/global.css b/web/src/theme/global.css index 0bb3978..a38bc01 100644 --- a/web/src/theme/global.css +++ b/web/src/theme/global.css @@ -1621,6 +1621,44 @@ button.row-card:hover:not(:disabled) { } .voice__hint { +/* ---------------------------------------------------------------- garden (#119, slide 13) */ + +.garden { + gap: var(--s-3); +} + +.garden__eyebrow { + margin: 0 0 2px; + font-size: var(--fs-12); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--c-teal-strong); +} + +.garden__scene { + display: block; + width: 100%; + height: auto; + max-height: 190px; +} + +.garden__plant--fresh { + animation: garden-grow 1100ms var(--ease) both; +} + +@keyframes garden-grow { + from { + transform: scaleY(0.2); + opacity: 0.3; + } + to { + transform: scaleY(1); + opacity: 1; + } +} + +.garden__title { margin: 0; font-weight: 600; color: var(--c-text); @@ -1662,6 +1700,84 @@ button.row-card:hover:not(:disabled) { .voice__privacy { margin: 0; +.garden__progress { + display: grid; + gap: var(--s-2); + padding: var(--s-3); + border-radius: var(--r-btn); + background: var(--c-tile); +} + +.garden__progress-head { + display: flex; + justify-content: space-between; + font-weight: 600; + font-size: var(--fs-14); +} + +.garden__bar { + height: 8px; + border-radius: 999px; + background: var(--c-border); + overflow: hidden; +} + +.garden__bar span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--c-primary); + transition: width 600ms var(--ease); +} + +.garden__today { + display: grid; + gap: var(--s-2); + margin: 0; + padding: 0; + list-style: none; +} + +.garden__item { + display: flex; + align-items: center; + gap: var(--s-3); + font-size: var(--fs-14); +} + +.garden__icon { + flex: none; + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: var(--r-btn); + background: var(--c-teal-soft); + color: var(--c-teal-strong); +} + +.garden__item-text { + flex: 1; +} + +.garden__check { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border: 1.5px solid var(--c-border-strong); + border-radius: 50%; + color: #fff; +} + +.garden__check--done { + border-color: var(--c-primary); + background: var(--c-primary); +} + +.garden__note { + margin: 0; + text-align: center; font-size: var(--fs-12); color: var(--c-text-3); }