feat: implement Sleepy sleep diary
This commit is contained in:
11
components/ServiceWorker.tsx
Normal file
11
components/ServiceWorker.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function ServiceWorker() {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) void navigator.serviceWorker.register('/sw.js');
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
115
components/SleepDiary.tsx
Normal file
115
components/SleepDiary.tsx
Normal 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">☾</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>; }
|
||||
Reference in New Issue
Block a user