Browse Source

Sprint 6: Таймлайн X home variant for chronic patients

Fourth home variant alongside Карточки / Лента / Таймлайн,
labeled "Таймлайн X" in Tweaks "Главный экран". Designed for
patients with chronic conditions (not post-op).

data.js: new `chronic` object — condition, stage, doctor,
metrics (days since last flare-up, compliance %, flare-ups this
year), daily + scheduled tasks, lifestyle recommendations, and
5-visit observation history.

HomeTimelineXScreen sections:
- Health status hero with soft gradient primary-100 → warm-100,
  success-tinted stable-state chip (green dot), three narrow-font
  metrics
- Today's tasks card: daily habits with streak + 🔥, scheduled
  procedures with dates
- Ask-a-question promotion: AI card (teal gradient) + doctor
  card (with avatar) side by side
- Vertical observation timeline: 5 past visits with doctor initial,
  color-coded by type (diagnosis/procedure/therapy/flareup/checkup)
- Upcoming appointment, lifestyle recommendations carousel with
  emoji, book CTA, article carousel

Softened hero from dark teal gradient with white text to light
primary-100 → warm-100 gradient with dark text — stable-state
signal, not clinical verdict.

Following Sprint 5 convention: matching entry in src/docs.js under
key `home:timelineX` with title, category, goal, tasks, rationale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main
parent
commit
bf1c78ff14
  1. 12
      SPRINTS.md
  2. 7
      src/App.jsx
  3. 4
      src/PhoneApp.jsx
  4. 28
      src/data.js
  5. 20
      src/docs.js
  6. 236
      src/screens/screens-home.jsx

12
SPRINTS.md

@ -101,6 +101,18 @@ _заполнить в конце спринта_
--- ---
## Спринт 6 · 20 апр 2026
**Цель:** вариант Главной для пациентов с хроническими заболеваниями — поддержание понимания «что со мной происходит», ежедневных задач по здоровью и связи с врачом/AI-помощником. Выведено как **Таймлайн X** (X = «хроник»).
### План
- [x] Добавить в `data.js` блок `chronic` — диагноз, стадия, ключевые метрики (дни без обострений, комплаенс, обострения в году), ежедневные/плановые задачи, рекомендации, история наблюдения (visits)
- [x] Экран `HomeTimelineXScreen` в `screens-home.jsx`: health-status hero, задачи сегодня (daily + scheduled), промо-блок связи (AI + врач), вертикальный таймлайн истории, ближайший приём, рекомендации горизонтально, CTA записи, статьи
- [x] Добавить `timelineX` в `HOME_OPTIONS` (App.jsx) и в HOME map (PhoneApp.jsx)
- [x] Описание в `src/docs.js` под ключом `home:timelineX` (соблюдение конвенции из Спринта 5)
---
## Спринт 5 · 20 апр 2026 ## Спринт 5 · 20 апр 2026
**Цель:** документация прототипа внутри самого прототипа — чтобы на ревью с коллегами можно было сразу увидеть цель и design-решения по любому экрану. **Цель:** документация прототипа внутри самого прототипа — чтобы на ревью с коллегами можно было сразу увидеть цель и design-решения по любому экрану.

7
src/App.jsx

@ -26,9 +26,10 @@ const SCALE_OPTIONS = [
]; ];
const HOME_OPTIONS = [ const HOME_OPTIONS = [
{ id: 'cards', lb: 'Карточки' }, { id: 'cards', lb: 'Карточки' },
{ id: 'list', lb: 'Лента' }, { id: 'list', lb: 'Лента' },
{ id: 'feed', lb: 'Таймлайн' }, { id: 'feed', lb: 'Таймлайн' },
{ id: 'timelineX', lb: 'Таймлайн X' },
]; ];
const DOC_OPTIONS = [ const DOC_OPTIONS = [
{ id: 'rich', lb: 'Карточки+' }, { id: 'rich', lb: 'Карточки+' },

4
src/PhoneApp.jsx

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { TabBar } from './components.jsx'; import { TabBar } from './components.jsx';
import { HomeCardsScreen, HomeListScreen, HomeFeedScreen } from './screens/screens-home.jsx'; import { HomeCardsScreen, HomeListScreen, HomeFeedScreen, HomeTimelineXScreen } from './screens/screens-home.jsx';
import { import {
BookingSpecsScreen, BookingDoctorScreen, BookingTimeScreen, BookingSpecsScreen, BookingDoctorScreen, BookingTimeScreen,
BookingConfirmScreen, BookingSuccessScreen, BookingConfirmScreen, BookingSuccessScreen,
@ -22,7 +22,7 @@ import { DocsScreen } from './screens/screens-docs.jsx';
function renderScreen(screenId, nav, ctx) { function renderScreen(screenId, nav, ctx) {
const parts = screenId.split(':'); const parts = screenId.split(':');
const id = parts[0]; const id = parts[0];
const HOME = { cards: HomeCardsScreen, list: HomeListScreen, feed: HomeFeedScreen }[ctx.homeVariant] || HomeCardsScreen; const HOME = { cards: HomeCardsScreen, list: HomeListScreen, feed: HomeFeedScreen, timelineX: HomeTimelineXScreen }[ctx.homeVariant] || HomeCardsScreen;
switch (id) { switch (id) {
case 'home': return <HOME nav={nav} ctx={ctx} />; case 'home': return <HOME nav={nav} ctx={ctx} />;
case 'home-v2': return <HomeV2Screen nav={nav} ctx={ctx} />; case 'home-v2': return <HomeV2Screen nav={nav} ctx={ctx} />;

28
src/data.js

@ -226,6 +226,34 @@ export const CLINIC_DATA = {
], ],
}, },
], ],
chronic: {
condition: 'Хронический тонзиллит',
stage: 'Компенсированная форма',
diagnosed: '2022',
doctorId: 'makarova',
daysSinceLastFlareup: 118,
complianceScore: 87,
flareupsThisYear: 1,
currentTasks: [
{ id: 't1', text: 'Полоскание горла (утро/вечер)', type: 'daily', done: true, streak: 12 },
{ id: 't2', text: 'Витамин D 2000 МЕ', type: 'daily', done: false, streak: 0 },
{ id: 't3', text: 'Промывание миндалин в клинике', type: 'scheduled', nextDate: '23 апр' },
{ id: 't4', text: 'Плановый ЛОР-осмотр', type: 'scheduled', nextDate: 'Июль 2026' },
],
recommendations: [
{ icon: '❄', title: 'Избегать переохлаждения', sub: 'Особенно горла и шеи' },
{ icon: '💧', title: 'Пить 1.5–2 л воды в день', sub: 'Увлажнение слизистой' },
{ icon: '🏃', title: 'Умеренные физнагрузки', sub: 'Укрепление иммунитета' },
{ icon: '🥦', title: 'Витамин C + цинк', sub: 'В рационе ежедневно' },
],
pastVisits: [
{ id: 'v1', date: '15 окт 2023', title: 'Диагноз подтверждён', doctorId: 'makarova', type: 'diagnosis' },
{ id: 'v2', date: '12 дек 2023', title: 'Первое промывание миндалин', doctorId: 'makarova', type: 'procedure' },
{ id: 'v3', date: '8 мар 2024', title: 'Курс физиотерапии · 10 сеансов', doctorId: 'makarova', type: 'therapy' },
{ id: 'v4', date: '14 дек 2024', title: 'Обострение — курс антибиотиков', doctorId: 'makarova', type: 'flareup' },
{ id: 'v5', date: '8 апр 2026', title: 'Плановый осмотр', doctorId: 'makarova', type: 'checkup' },
],
},
recovery: { recovery: {
op: 'Септопластика', op: 'Септопластика',
surgeon: 'syndaev', surgeon: 'syndaev',

20
src/docs.js

@ -55,6 +55,26 @@ export const SCREEN_DOCS = {
'Accent-CTA (красная) для записи — выделяется на фоне тёплых карточек', 'Accent-CTA (красная) для записи — выделяется на фоне тёплых карточек',
], ],
}, },
'home:timelineX': {
title: 'Главная 1 · Таймлайн X',
category: 'Главная',
goal: 'Для пациентов с хроническими заболеваниями: поддержание общего понимания «что со мной происходит», ежедневные задачи по здоровью, история наблюдения и лёгкий вход в связь с врачом или AI-помощником.',
tasks: [
'Увидеть текущее состояние: диагноз, стадия, ключевые метрики (дни без обострений, комплаенс)',
'Отметить выполненные ежедневные задачи (полоскание, витамины)',
'Увидеть запланированные процедуры и осмотры с датами',
'Быстро спросить AI-помощника или написать лечащему врачу',
'Пролистать историю наблюдения (визиты, обострения, курсы)',
'Записаться на следующий осмотр',
],
rationale: [
'Светлый health-status hero (primary-100 → warm-100) — спокойная справка о состоянии, не клинический приговор. Статус «Компенсированная форма» с зелёной точкой + success-цветом сигнализирует «всё стабильно».',
'Задачи разделены на daily (с streak-счётчиком и огнём 🔥) и scheduled (с датой) — разная природа действий',
'Промо-блок связи (AI + врач) вынесен отдельным блоком с градиентной AI-карточкой — снять психологический барьер обращения',
'Вертикальный таймлайн визитов с инициалами врача и цветовой семантикой по типу (диагноз/процедура/терапия/обострение/осмотр) — continuity of care одним взглядом',
'Рекомендации emoji-карточками горизонтально — лёгкое lifestyle-чтение, не давит на верхние задачи',
],
},
'home-v2': { 'home-v2': {
title: 'Главная 2', title: 'Главная 2',
category: 'Главная', category: 'Главная',

236
src/screens/screens-home.jsx

@ -202,6 +202,242 @@ export function HomeListScreen({ nav }) {
); );
} }
export function HomeTimelineXScreen({ nav }) {
const { doctors, appointments, clinic, articles, chronic } = CLINIC_DATA;
const upcoming = appointments.find(a => a.status === 'upcoming');
const upDoc = upcoming && doctors.find(d => d.id === upcoming.doctor);
const myDoctor = doctors.find(d => d.id === chronic.doctorId);
const typeColors = {
diagnosis: { bg: 'var(--c-primary-100)', fg: 'var(--c-primary-darker)' },
procedure: { bg: 'var(--c-primary-100)', fg: 'var(--c-primary-darker)' },
therapy: { bg: 'var(--c-warm-100)', fg: 'var(--c-warm-text)' },
flareup: { bg: 'var(--c-accent-50)', fg: 'var(--c-accent)' },
checkup: { bg: 'var(--c-success-50)', fg: 'var(--c-success)' },
};
return (
<div style={{ paddingBottom: 100 }}>
{/* Greeting header */}
<div style={{ padding: '8px 20px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div className="sub">20 апреля, понедельник</div>
<div style={{ fontSize: 22, fontWeight: 700 }}>Здравствуйте, Анна</div>
</div>
<button onClick={() => nav.push('profile')} className="press">
<Avatar init="АС" size={42} />
</button>
</div>
</div>
{/* Health status hero */}
<div style={{ padding: '0 16px 16px' }}>
<div className="card" style={{
padding: 18,
background: 'linear-gradient(135deg, var(--c-primary-100), var(--c-warm-100))',
border: '1px solid var(--c-primary-200)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: .8, color: 'var(--c-primary-darker)', fontWeight: 700 }}>Ваше состояние</span>
<span style={{
padding: '4px 10px', borderRadius: 999,
background: 'rgba(255,255,255,0.75)', color: 'var(--c-success)',
fontSize: 11, fontWeight: 700, display: 'inline-flex', alignItems: 'center', gap: 5,
}}>
<span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--c-success)' }} />
{chronic.stage}
</span>
</div>
<div style={{ fontSize: 19, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 4 }}>{chronic.condition}</div>
<div style={{ fontSize: 12, color: 'var(--c-fg-3)', marginBottom: 16 }}>
Наблюдение с {chronic.diagnosed} · {myDoctor.name.split(' ').slice(0, 2).join(' ')}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8, paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.6)' }}>
<div>
<div style={{ fontFamily: 'var(--font-narrow)', fontSize: 26, fontWeight: 700, lineHeight: 1, color: 'var(--c-primary-darker)' }}>{chronic.daysSinceLastFlareup}</div>
<div style={{ fontSize: 10, color: 'var(--c-fg-3)', marginTop: 3 }}>дней без обострений</div>
</div>
<div>
<div style={{ fontFamily: 'var(--font-narrow)', fontSize: 26, fontWeight: 700, lineHeight: 1, color: 'var(--c-primary-darker)' }}>{chronic.complianceScore}%</div>
<div style={{ fontSize: 10, color: 'var(--c-fg-3)', marginTop: 3 }}>комплаенс</div>
</div>
<div>
<div style={{ fontFamily: 'var(--font-narrow)', fontSize: 26, fontWeight: 700, lineHeight: 1, color: 'var(--c-primary-darker)' }}>{chronic.flareupsThisYear}</div>
<div style={{ fontSize: 10, color: 'var(--c-fg-3)', marginTop: 3 }}>обострение в году</div>
</div>
</div>
</div>
</div>
{/* Current tasks */}
<div style={{ padding: '0 20px 18px' }}>
<SectionHeader title="Задачи сегодня" pad="0 0 8px 0" action="История" onAction={() => nav.push('medcard')} />
<div className="card" style={{ padding: 0 }}>
{chronic.currentTasks.map((t, i, a) => {
const isDaily = t.type === 'daily';
return (
<React.Fragment key={t.id}>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '12px 14px' }}>
{isDaily ? (
<div style={{
width: 28, height: 28, borderRadius: 999, flexShrink: 0,
background: t.done ? 'var(--c-primary-darker)' : '#fff',
border: t.done ? 0 : '2px solid var(--c-border-strong)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{t.done && <I.check size={16} style={{ color: '#fff' }} sw={3} />}
</div>
) : (
<div style={{
width: 28, height: 28, borderRadius: 8, flexShrink: 0,
background: 'var(--c-primary-100)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<I.calendar size={14} style={{ color: 'var(--c-primary-darker)' }} />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, fontWeight: 600,
color: isDaily && t.done ? 'var(--c-fg-3)' : 'var(--c-fg-1)',
textDecoration: isDaily && t.done ? 'line-through' : 'none',
}}>{t.text}</div>
<div className="sub" style={{ fontSize: 11 }}>
{isDaily ? (t.streak > 0 ? `Серия: ${t.streak} дней` : 'Ежедневно') : `До ${t.nextDate}`}
</div>
</div>
{isDaily && t.streak > 0 && <span style={{ fontSize: 18 }}>🔥</span>}
{!isDaily && <I.chev size={15} style={{ color: 'var(--c-fg-4)' }} />}
</div>
{i < a.length - 1 && <div style={{ height: 1, background: 'var(--c-divider)', marginLeft: 54 }} />}
</React.Fragment>
);
})}
</div>
</div>
{/* Promotion: ask AI or doctor */}
<div style={{ padding: '0 20px 18px' }}>
<SectionHeader title="Есть вопрос?" pad="0 0 8px 0" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<button onClick={() => nav.push('chat:ai')} className="press" style={{
padding: 14, borderRadius: 16,
background: 'linear-gradient(135deg, var(--c-primary-darker), var(--c-primary-dark))',
color: '#fff', textAlign: 'left', display: 'flex', flexDirection: 'column', gap: 6,
}}>
<div style={{ fontSize: 26 }}></div>
<div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 2 }}>Спросить помощника</div>
<div style={{ fontSize: 11, opacity: .85, lineHeight: 1.4 }}>Быстрый ответ круглосуточно</div>
</div>
</button>
<button onClick={() => nav.push('chat:doctor-syndaev')} className="press card" style={{
padding: 14, textAlign: 'left', display: 'flex', flexDirection: 'column', gap: 6,
}}>
<Avatar init={myDoctor.init} size={32} />
<div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 2 }}>Написать врачу</div>
<div className="sub" style={{ fontSize: 11, lineHeight: 1.4 }}>{myDoctor.name.split(' ').slice(0, 2).join(' ')}</div>
</div>
</button>
</div>
</div>
{/* History timeline */}
<div style={{ padding: '0 20px 18px' }}>
<SectionHeader title="История наблюдения" pad="0 0 8px 0" action="Вся карта" onAction={() => nav.push('medcard')} />
<div className="card" style={{ padding: '8px 0' }}>
{chronic.pastVisits.map((v, i, a) => {
const d = doctors.find(x => x.id === v.doctorId);
const c = typeColors[v.type] || typeColors.checkup;
const isLast = i === a.length - 1;
return (
<div key={v.id} style={{ display: 'flex', gap: 14, padding: '10px 14px', alignItems: 'flex-start', position: 'relative' }}>
{!isLast && (
<div style={{
position: 'absolute', left: 27, top: 32, bottom: -6,
width: 2, background: 'var(--c-divider)',
}} />
)}
<div style={{
width: 28, height: 28, borderRadius: 999, flexShrink: 0,
background: c.bg, color: c.fg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700,
position: 'relative', zIndex: 1,
}}>
{d.init}
</div>
<div style={{ flex: 1, paddingBottom: 4, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--c-fg-1)', marginBottom: 2, lineHeight: 1.35 }}>{v.title}</div>
<div className="sub" style={{ fontSize: 11 }}>{v.date} · {d.name.split(' ').slice(0, 2).join(' ')}</div>
</div>
</div>
);
})}
</div>
</div>
{/* Upcoming appointment */}
{upcoming && upDoc && (
<div style={{ padding: '0 20px 18px' }}>
<SectionHeader title="Ближайший приём" pad="0 0 8px 0" />
<AppointmentCard appt={upcoming} doctor={upDoc} addr={clinic.addresses.find(a => a.id === upcoming.address)} onClick={() => nav.push('appt:' + upcoming.id)} />
</div>
)}
{/* Recommendations */}
<div style={{ padding: '0 0 18px' }}>
<SectionHeader title="Рекомендации" />
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', padding: '0 20px 8px' }} className="noscroll">
{chronic.recommendations.map((r, i) => (
<div key={i} className="card" style={{
flexShrink: 0, width: 170, padding: 14,
}}>
<div style={{ fontSize: 28, marginBottom: 8, lineHeight: 1 }}>{r.icon}</div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 3, lineHeight: 1.3 }}>{r.title}</div>
<div className="sub" style={{ fontSize: 11, lineHeight: 1.4 }}>{r.sub}</div>
</div>
))}
</div>
</div>
{/* Book CTA */}
<div style={{ padding: '0 20px 18px' }}>
<button onClick={() => nav.push('booking-specs')} className="btn-p block">
<I.plus size={18} /> Записаться на осмотр
</button>
</div>
{/* Articles */}
<div style={{ padding: '0 0 8px' }}>
<SectionHeader title="Полезное чтение" action="Все" onAction={() => nav.push('articles')} />
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', padding: '0 20px 8px' }} className="noscroll">
{articles.slice(0, 3).map(a => (
<button key={a.id} onClick={() => nav.push('article:' + a.id)} className="press card" style={{
flexShrink: 0, width: 200, padding: 0, overflow: 'hidden', textAlign: 'left',
}}>
<div style={{
height: 80, background: a.hero,
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', padding: 10,
}}>
<span className="chip chip-warm" style={{ background: 'rgba(255,255,255,0.85)' }}>{a.tag}</span>
<span style={{ fontSize: 24, lineHeight: 1 }}>{a.emoji}</span>
</div>
<div style={{ padding: 12 }}>
<div style={{ fontSize: 13, fontWeight: 700, lineHeight: 1.3, marginBottom: 6, minHeight: 34 }}>{a.title}</div>
<div className="sub" style={{ fontSize: 11 }}>{a.mins} мин</div>
</div>
</button>
))}
</div>
</div>
</div>
);
}
export function HomeFeedScreen({ nav }) { export function HomeFeedScreen({ nav }) {
const { doctors, appointments, clinic, articles, recovery } = CLINIC_DATA; const { doctors, appointments, clinic, articles, recovery } = CLINIC_DATA;
const upcoming = appointments.find(a => a.status === 'upcoming'); const upcoming = appointments.find(a => a.status === 'upcoming');

Loading…
Cancel
Save