feat: implement Sleepy sleep diary

This commit is contained in:
2026-10-11 12:45:09 +07:00
commit a0fa0e0e56
27 changed files with 8356 additions and 0 deletions

115
components/SleepDiary.tsx Normal file
View File

@@ -0,0 +1,115 @@
'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>; }