Files
sleepy/components/SleepDiary.tsx

116 lines
9.8 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.
'use client';
import { useEffect, useState, type CSSProperties } from 'react';
import { calculateMetrics, defaultEntry, formatDuration, type SleepEntry } from '@/lib/sleep';
const dayFormatter = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
function localDate(value: Date): string {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 10);
}
function shiftDate(date: string, days: number): string {
const value = new Date(`${date}T12:00:00`);
value.setDate(value.getDate() + days);
return localDate(value);
}
export function SleepDiary() {
const [date, setDate] = useState(() => localDate(new Date()));
const [entry, setEntry] = useState<SleepEntry>(() => defaultEntry(localDate(new Date())));
const [status, setStatus] = useState<'loading' | 'idle' | 'saving' | 'saved' | 'error'>('loading');
useEffect(() => {
let active = true;
fetch(`/api/entries/${date}`)
.then(async (response) => {
if (!response.ok) throw new Error('Не удалось загрузить запись');
return response.json() as Promise<{ entry: SleepEntry | null }>;
})
.then(({ entry: savedEntry }) => {
if (!active) return;
setEntry(savedEntry ?? defaultEntry(date));
setStatus('idle');
})
.catch(() => active && setStatus('error'));
return () => { active = false; };
}, [date]);
const metrics = calculateMetrics(entry);
const filled = [entry.bedtime, entry.riseTime, entry.sleepLatencyMinutes, entry.awakenings, entry.wasoMinutes, entry.rating]
.filter((value) => value !== '' && value !== null).length;
function update<K extends keyof SleepEntry>(field: K, value: SleepEntry[K]) {
setEntry((current) => ({ ...current, [field]: value }));
setStatus('idle');
}
async function save() {
setStatus('saving');
try {
const response = await fetch(`/api/entries/${date}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry),
});
if (!response.ok) throw new Error();
const result = await response.json() as { entry: SleepEntry };
setEntry(result.entry);
setStatus('saved');
} catch {
setStatus('error');
}
}
return (
<main className="container">
<header className="hero">
<div className="brand"><span className="brand-mark" aria-hidden="true">&#9790;</span><div><h1>Sleepy</h1><p>дневник сна</p></div></div>
<section className="date-card" aria-label="Дата записи">
<div><span className="eyebrow">Дата записи</span><h2>{dayFormatter.format(new Date(`${date}T12:00:00`))}</h2></div>
<div className="date-controls"><button onClick={() => setDate(shiftDate(date, -1))} aria-label="Предыдущий день">←</button><button onClick={() => setDate(shiftDate(date, 1))} aria-label="Следующий день">→</button></div>
<p>Заполнено: <strong>{filled} из 6</strong>. Достаточно, чтобы увидеть картину ночи.</p>
<div className="progress"><span style={{ width: `${(filled / 6) * 100}%` }} /></div>
</section>
</header>
<div className="layout">
<section className="form-card" aria-label="Форма дневника сна">
<Step number="1" title="Дневной сон вчера" hint="Если спали днём, укажите примерную длительность.">
<div className="segmented"><button className={!entry.napped ? 'active' : ''} onClick={() => update('napped', false)}>Нет</button><button className={entry.napped ? 'active' : ''} onClick={() => update('napped', true)}>Да</button></div>
{entry.napped && <div className="two-columns"><Field label="Во сколько задремали"><input type="time" value={entry.napStart ?? ''} onChange={(event) => update('napStart', event.target.value || null)} /></Field><Field label="Длительность, минут"><input type="number" min="0" max="300" value={entry.napDurationMinutes ?? ''} onChange={(event) => update('napDurationMinutes', Number(event.target.value) || null)} /></Field></div>}
</Step>
<Step number="2" title="Отход ко сну и засыпание" hint="ВОС - когда легли и выключили свет. ЛПС - сколько засыпали.">
<div className="two-columns"><Field label="Время отхода ко сну, ВОС"><input type="time" value={entry.bedtime} onChange={(event) => update('bedtime', event.target.value)} /></Field><Field label="Латентный период, ЛПС, минут"><input type="number" min="0" max="300" value={entry.sleepLatencyMinutes} onChange={(event) => update('sleepLatencyMinutes', Number(event.target.value))} /></Field></div>
<div className="chips">{[5, 15, 30, 60, 90].map((minutes) => <button key={minutes} className={entry.sleepLatencyMinutes === minutes ? 'selected' : ''} onClick={() => update('sleepLatencyMinutes', minutes)}>{minutes}{minutes === 90 ? '+' : ''} мин</button>)}</div>
</Step>
<Step number="3" title="Ночные пробуждения" hint="ПБПЗ - общее время бодрствования после засыпания.">
<div className="two-columns"><Field label="Частота пробуждений, ЧНП"><div className="stepper"><button onClick={() => update('awakenings', Math.max(0, entry.awakenings - 1))}>−</button><output>{entry.awakenings}</output><button onClick={() => update('awakenings', entry.awakenings + 1)}>+</button><span>раз за ночь</span></div></Field><Field label="ПБПЗ, минут всего"><input type="number" min="0" max="600" value={entry.wasoMinutes} onChange={(event) => update('wasoMinutes', Number(event.target.value))} /></Field></div>
</Step>
<Step number="4" title="Утро" hint="ВПП - когда окончательно встали с постели.">
<div className="two-columns"><Field label="Время подъёма, ВПП"><input type="time" value={entry.riseTime} onChange={(event) => update('riseTime', event.target.value)} /></Field><Field label="Как проснулись"><select value={entry.alarmType} onChange={(event) => update('alarmType', event.target.value as SleepEntry['alarmType'])}><option value="natural">Сам(а), без будильника</option><option value="alarm">По будильнику</option><option value="awakened">Разбудили</option></select></Field></div>
</Step>
<Step number="5" title="Оценка сна" hint="Субъективное ощущение также важно для врача.">
<div className="rating">{['совсем не спал(а)', 'плохо', 'терпимо', 'хорошо', 'отлично'].map((label, index) => { const value = index + 1; return <button key={value} className={entry.rating === value ? 'selected' : ''} onClick={() => update('rating', value)}><b>{value}</b><span>{label}</span></button>; })}</div>
<Field label="Заметка для врача (необязательно)"><textarea rows={3} value={entry.notes} placeholder="Лекарства, тревога, кофе, кошмары..." onChange={(event) => update('notes', event.target.value)} /></Field>
</Step>
</section>
<aside className="summary" aria-live="polite">
<span className="badge">Считается автоматически</span><h2>Ваша ночь в цифрах</h2><p>Показатели меняются, пока вы заполняете дневник.</p>
<div className="efficiency"><div className="ring" style={{ '--progress': `${Math.min(metrics.efficiency, 100) * 3.6}deg` } as CSSProperties}><span>{metrics.efficiency}%</span></div><div><span>Эффективность сна</span><strong>{metrics.efficiency}%</strong></div></div>
<dl><div><dt>Время в постели, ВП</dt><dd>{formatDuration(metrics.timeInBedMinutes)}</dd></div><div><dt>ЛПС</dt><dd>{formatDuration(entry.sleepLatencyMinutes)}</dd></div><div><dt>ПБПЗ</dt><dd>{formatDuration(entry.wasoMinutes)}</dd></div><div className="total"><dt>Общее время сна, ОВС</dt><dd>{formatDuration(metrics.totalSleepMinutes)}</dd></div></dl>
<p className="interpretation">{metrics.efficiency >= 85 ? 'Хорошая эффективность сна.' : metrics.efficiency >= 75 ? 'Эффективность умеренная. Отмечайте динамику несколько дней.' : 'Низкая эффективность. Обсудите регулярные наблюдения со специалистом.'}</p>
<button className="save" onClick={save} disabled={status === 'saving' || status === 'loading'}>{status === 'saving' ? 'Сохраняем...' : 'Сохранить запись'}</button>
<p className={`save-status ${status}`}>{status === 'saved' ? 'Запись сохранена' : status === 'error' ? 'Не удалось сохранить запись' : status === 'loading' ? 'Загружаем запись...' : ''}</p>
</aside>
</div>
<footer>Sleepy хранит записи только в вашей локальной базе данных.</footer>
</main>
);
}
function Step({ number, title, hint, children }: { number: string; title: string; hint: string; children: React.ReactNode }) { return <section className="step"><div className="step-heading"><span>{number}</span><div><h2>{title}</h2><p>{hint}</p></div></div>{children}</section>; }
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <label className="field"><span>{label}</span>{children}</label>; }