116 lines
9.8 KiB
TypeScript
116 lines
9.8 KiB
TypeScript
'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">☾</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>; }
|