feat: implement Sleepy sleep diary
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.refs
|
||||
data
|
||||
npm-debug.log*
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
!.gitkeep
|
||||
.env
|
||||
npm-debug.log*
|
||||
21
AGENTS.md
Normal file
21
AGENTS.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Sleepy -- трекер сна
|
||||
|
||||
Sleepy -- веб-приложение на Next.js и SQLite для трекинга сна для людей, наблюдающихся у психиатра. Адаптированно под PWA
|
||||
|
||||
## Спецификация
|
||||
Для людей, имеющих проблемы со сном, существует таблица отслеживания сна, которой пользуются многие психиатры для наблюдения за пациентом. Таблица задаётся следующими полями:
|
||||
1. Спали ли вчера днём (Да/Нет, длительность)
|
||||
2. Время отхода ко сну (ВОС)
|
||||
3. Латентный период начала сна (ЛПС) (сколько времени засыпали)
|
||||
4. Частота ночных пробуждений (ЧНП)
|
||||
5. Продолжительность бодрствования после засыпания (ПБПЗ) (сколько раз пробуждались ночью и общее время бодрствования). Пример: 3*20 = 60 минут
|
||||
6. Время подъёма с постели (ВПП)
|
||||
7. Время в постели (ВП)
|
||||
8. Общее время сна (ОВС) = (ВП-(ЛПС+ПБПЗ))
|
||||
9. Эффективность сна (ОВС/ВП * 100)
|
||||
10. Оценка сна (1-5)
|
||||
|
||||
## Технические особенности
|
||||
Основной стек - Next.JS + SQLite. Должен быть docker compose для быстрого и простого деплоя на VPS. В папке `.refs/` находятся пример страницы, который ты должен повторить.
|
||||
Упор необходимо сделать на PWA и лёгкость использования. Авторизации и разделения по пользователям не нужно -- это selfhost утилита для индивидуального пользования.
|
||||
favicon находится в корне.
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
RUN mkdir -p /data
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
29
README.md
Normal file
29
README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Sleepy
|
||||
|
||||
Минималистичный PWA-дневник сна для личного наблюдения и подготовки записей к приёму у специалиста.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Дневная запись сна: дневной сон, отход ко сну, латентность, пробуждения, подъём и субъективная оценка.
|
||||
- Автоматический расчёт времени в постели (ВП), общего времени сна (ОВС) и эффективности сна.
|
||||
- Локальная SQLite-база без аккаунтов и внешних сервисов.
|
||||
- Устанавливаемое PWA-приложение, адаптированное для телефона.
|
||||
|
||||
## Запуск
|
||||
|
||||
Требуется Node.js 22 или новее.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Откройте `http://localhost:3000`. SQLite-файл будет создан в `data/sleepy.db`.
|
||||
|
||||
## Деплой с Docker
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Приложение доступно на `http://<сервер>:3000`. Данные находятся в именованном томе `sleepy-data` и сохраняются между обновлениями контейнера.
|
||||
28
app/api/entries/[date]/route.ts
Normal file
28
app/api/entries/[date]/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getEntry, saveEntry } from '@/lib/db';
|
||||
import type { SleepEntry } from '@/lib/sleep';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function isDate(value: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||
}
|
||||
|
||||
export async function GET(_: Request, { params }: { params: Promise<{ date: string }> }) {
|
||||
const { date } = await params;
|
||||
if (!isDate(date)) return NextResponse.json({ error: 'Некорректная дата' }, { status: 400 });
|
||||
|
||||
return NextResponse.json({ entry: getEntry(date) });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ date: string }> }) {
|
||||
const { date } = await params;
|
||||
if (!isDate(date)) return NextResponse.json({ error: 'Некорректная дата' }, { status: 400 });
|
||||
|
||||
const entry = await request.json() as SleepEntry;
|
||||
if (entry.date !== date || entry.rating < 1 || entry.rating > 5 || entry.awakenings < 0) {
|
||||
return NextResponse.json({ error: 'Проверьте заполненные значения' }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ entry: saveEntry(entry) });
|
||||
}
|
||||
9
app/globals.css
Normal file
9
app/globals.css
Normal file
@@ -0,0 +1,9 @@
|
||||
:root { --bg:#f5f4ed; --surface:#faf9f5; --warm:#e8e6dc; --ink:#141413; --text:#3d3d3a; --muted:#78776f; --line:#e3e1d7; --accent:#c96442; --focus:#3898ec; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; background:var(--bg); color:var(--ink); font-family:Arial, system-ui, sans-serif; line-height:1.55; }
|
||||
button,input,select,textarea { font:inherit; } button { cursor:pointer; } button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible { outline:3px solid color-mix(in srgb, var(--focus) 35%, transparent); outline-offset:2px; }
|
||||
.container { width:min(1200px, calc(100% - 32px)); margin:auto; }.hero { display:grid; grid-template-columns:1fr 380px; gap:48px; align-items:start; padding:34px 0 28px; }.brand { display:flex; align-items:center; gap:12px; padding-top:10px; }.brand-mark { display:grid; place-items:center; width:42px; height:42px; border-radius:50%; background:var(--ink); color:var(--bg); font:24px Georgia,serif; }.brand h1,h2 { margin:0; font-family:Georgia,serif; font-weight:500; letter-spacing:-.03em; }.brand h1 { font-size:28px; }.brand p { margin:0; color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.1em; }
|
||||
.date-card,.form-card { background:var(--surface); border:1px solid var(--line); box-shadow:0 5px 24px #0000000c; border-radius:16px; }.date-card { padding:20px; display:grid; grid-template-columns:1fr auto; gap:8px 18px; }.date-card h2 { font-size:20px; }.eyebrow,.badge { color:var(--muted); font-size:11px; letter-spacing:.09em; text-transform:uppercase; }.date-card p { grid-column:1/-1; margin:6px 0 0; color:var(--muted); font-size:13px; }.date-card strong { color:var(--text); }.date-controls { display:flex; gap:8px; }.date-controls button,.stepper button { width:42px; height:42px; border:1px solid var(--line); border-radius:8px; color:var(--ink); background:var(--surface); font-size:20px; }.date-controls button:hover,.stepper button:hover { background:var(--warm); }.progress { grid-column:1/-1; height:7px; overflow:hidden; border-radius:99px; background:var(--warm); }.progress span { display:block; height:100%; border-radius:inherit; background:var(--text); transition:width .2s ease; }
|
||||
.layout { display:grid; grid-template-columns:minmax(0,1fr) 380px; align-items:start; gap:28px; padding-bottom:48px; }.form-card { padding:0 28px; }.step { padding:28px 0; border-bottom:1px solid var(--line); }.step:last-child { border:0; }.step-heading { display:flex; gap:16px; margin-bottom:18px; }.step-heading>span { display:grid; flex:0 0 auto; place-items:center; width:36px; height:36px; border:1px solid var(--line); border-radius:50%; background:var(--warm); color:var(--text); font-family:Georgia,serif; }.step h2 { font-size:24px; }.step-heading p { margin:5px 0 0; color:var(--muted); font-size:14px; }.two-columns { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-top:16px; }.field { display:block; margin-top:16px; color:var(--text); font-size:14px; font-weight:600; }.field span { display:block; margin-bottom:7px; }.field input,.field select,.field textarea { width:100%; min-height:48px; padding:11px 13px; color:var(--ink); background:var(--surface); border:1px solid var(--line); border-radius:12px; }.field textarea { resize:vertical; }.segmented { display:inline-flex; gap:4px; padding:4px; border:1px solid var(--line); border-radius:999px; background:var(--warm); }.segmented button { min-width:78px; min-height:39px; padding:0 16px; color:var(--muted); border:0; border-radius:999px; background:transparent; }.segmented button.active { color:var(--bg); background:var(--ink); }.chips { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; }.chips button { min-height:38px; padding:0 13px; border:1px solid var(--line); border-radius:99px; color:var(--text); background:var(--surface); }.chips button.selected,.rating button.selected { color:var(--bg); background:var(--ink); border-color:var(--ink); }.stepper { display:flex; align-items:center; gap:10px; }.stepper output { min-width:48px; text-align:center; font:28px Georgia,serif; }.stepper span { margin:0; color:var(--muted); font-weight:400; font-size:13px; }.rating { display:grid; grid-template-columns:repeat(5,1fr); gap:8px; }.rating button { min-height:74px; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:3px; padding:8px; color:var(--text); border:1px solid var(--line); border-radius:12px; background:var(--surface); }.rating b { font:23px Georgia,serif; }.rating span { font-size:11px; color:var(--muted); }.rating button.selected span { color:var(--bg); }
|
||||
.summary { position:sticky; top:24px; padding:28px; color:var(--bg); border-radius:16px; background:var(--ink); box-shadow:0 5px 24px #00000018; }.summary .badge { display:inline-block; padding:5px 10px; color:var(--bg); border-radius:99px; background:#ffffff1c; }.summary h2 { margin-top:12px; font-size:27px; }.summary>p { margin:4px 0; color:#dad8ce; font-size:14px; }.efficiency { display:flex; align-items:center; gap:18px; margin:21px 0; padding:16px; border:1px solid #ffffff18; border-radius:12px; background:#ffffff0b; }.ring { --progress:0deg; display:grid; place-items:center; width:100px; aspect-ratio:1; border-radius:50%; background:conic-gradient(var(--bg) var(--progress), #ffffff2b 0); }.ring::before { content:''; position:absolute; width:78px; aspect-ratio:1; border-radius:50%; background:var(--ink); }.ring span { z-index:1; font:22px Georgia,serif; }.efficiency div:last-child span { color:#c5c2b8; font-size:12px; }.efficiency strong { display:block; font:28px Georgia,serif; }.summary dl { margin:0; }.summary dl div { display:grid; grid-template-columns:1fr auto; gap:12px; padding:9px 0; font-size:14px; }.summary dt { color:#c5c2b8; }.summary dd { margin:0; font-family:ui-monospace,monospace; }.summary .total { margin-top:4px; padding-top:14px; border-top:1px solid #ffffff21; }.summary .total dd { font-weight:700; }.interpretation { margin:18px 0!important; padding:12px; color:#edeae1!important; border-radius:8px; background:#ffffff12; }.save { width:100%; min-height:48px; color:var(--surface); border:0; border-radius:8px; background:var(--accent); font-weight:700; }.save:disabled { cursor:wait; opacity:.7; }.save-status { min-height:20px; margin-top:8px!important; text-align:center; }.save-status.error { color:#ffb3a1; }.save-status.saved { color:#9ee0bd; } footer { padding:0 0 38px; color:var(--muted); font-size:13px; }
|
||||
@media (max-width:980px) { .hero,.layout { grid-template-columns:1fr; gap:20px; }.summary { position:static; }.date-card { max-width:560px; } } @media (max-width:600px) { .container { width:min(100% - 24px, 1200px); }.hero { padding-top:18px; }.form-card { padding:0 18px; }.step { padding:23px 0; }.two-columns { grid-template-columns:1fr; gap:0; }.rating { gap:5px; }.rating button { min-height:56px; }.rating span { display:none; }.summary { padding:21px; }.date-card { padding:16px; } } @media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; transition:none!important; } }
|
||||
17
app/layout.tsx
Normal file
17
app/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { ServiceWorker } from '@/components/ServiceWorker';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sleepy - дневник сна',
|
||||
description: 'Простой дневник сна для наблюдения за динамикой.',
|
||||
manifest: '/manifest.webmanifest',
|
||||
icons: { icon: '/favicon.svg', apple: '/favicon.svg' },
|
||||
appleWebApp: { capable: true, title: 'Sleepy', statusBarStyle: 'default' },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = { themeColor: '#f5f4ed' };
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return <html lang="ru"><body><ServiceWorker />{children}</body></html>;
|
||||
}
|
||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SleepDiary } from '@/components/SleepDiary';
|
||||
|
||||
export default function Home() {
|
||||
return <SleepDiary />;
|
||||
}
|
||||
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>; }
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
sleepy:
|
||||
build: .
|
||||
container_name: sleepy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATA_DIR: /data
|
||||
volumes:
|
||||
- sleepy-data:/data
|
||||
|
||||
volumes:
|
||||
sleepy-data:
|
||||
7
eslint.config.mjs
Normal file
7
eslint.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
|
||||
export default defineConfig([
|
||||
...nextVitals,
|
||||
globalIgnores(['.next/**', 'node_modules/**']),
|
||||
]);
|
||||
BIN
favicon.ico
Normal file
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
1
favicon.svg
Normal file
1
favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#f5f4ed"/><circle cx="256" cy="256" r="168" fill="#141413"/><text x="256" y="322" text-anchor="middle" font-family="Georgia, serif" font-size="200" fill="#f5f4ed">☾</text><circle cx="356" cy="176" r="18" fill="#c96442"/></svg>
|
||||
|
After Width: | Height: | Size: 337 B |
86
lib/db.ts
Normal file
86
lib/db.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { SleepEntry } from './sleep';
|
||||
|
||||
const dataDirectory = process.env.DATA_DIR ?? join(process.cwd(), 'data');
|
||||
mkdirSync(dataDirectory, { recursive: true });
|
||||
|
||||
const database = new Database(join(dataDirectory, 'sleepy.db'));
|
||||
database.pragma('journal_mode = WAL');
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sleep_entries (
|
||||
date TEXT PRIMARY KEY NOT NULL,
|
||||
napped INTEGER NOT NULL DEFAULT 0,
|
||||
nap_start TEXT,
|
||||
nap_duration_minutes INTEGER,
|
||||
bedtime TEXT NOT NULL,
|
||||
sleep_latency_minutes INTEGER NOT NULL,
|
||||
awakenings INTEGER NOT NULL,
|
||||
waso_minutes INTEGER NOT NULL,
|
||||
rise_time TEXT NOT NULL,
|
||||
alarm_type TEXT NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
type SleepEntryRow = Omit<SleepEntry, 'napped' | 'napStart' | 'napDurationMinutes' | 'sleepLatencyMinutes' | 'wasoMinutes' | 'riseTime' | 'alarmType' | 'createdAt' | 'updatedAt'> & {
|
||||
napped: number;
|
||||
nap_start: string | null;
|
||||
nap_duration_minutes: number | null;
|
||||
sleep_latency_minutes: number;
|
||||
waso_minutes: number;
|
||||
rise_time: string;
|
||||
alarm_type: SleepEntry['alarmType'];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function mapEntry(row: SleepEntryRow): SleepEntry {
|
||||
return {
|
||||
date: row.date,
|
||||
napped: Boolean(row.napped),
|
||||
napStart: row.nap_start,
|
||||
napDurationMinutes: row.nap_duration_minutes,
|
||||
bedtime: row.bedtime,
|
||||
sleepLatencyMinutes: row.sleep_latency_minutes,
|
||||
awakenings: row.awakenings,
|
||||
wasoMinutes: row.waso_minutes,
|
||||
riseTime: row.rise_time,
|
||||
alarmType: row.alarm_type,
|
||||
rating: row.rating,
|
||||
notes: row.notes,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEntry(date: string): SleepEntry | null {
|
||||
const row = database.prepare('SELECT * FROM sleep_entries WHERE date = ?').get(date) as SleepEntryRow | undefined;
|
||||
return row ? mapEntry(row) : null;
|
||||
}
|
||||
|
||||
export function saveEntry(entry: SleepEntry): SleepEntry {
|
||||
database.prepare(`
|
||||
INSERT INTO sleep_entries (
|
||||
date, napped, nap_start, nap_duration_minutes, bedtime, sleep_latency_minutes,
|
||||
awakenings, waso_minutes, rise_time, alarm_type, rating, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(date) DO UPDATE SET
|
||||
napped = excluded.napped, nap_start = excluded.nap_start,
|
||||
nap_duration_minutes = excluded.nap_duration_minutes, bedtime = excluded.bedtime,
|
||||
sleep_latency_minutes = excluded.sleep_latency_minutes, awakenings = excluded.awakenings,
|
||||
waso_minutes = excluded.waso_minutes, rise_time = excluded.rise_time,
|
||||
alarm_type = excluded.alarm_type, rating = excluded.rating, notes = excluded.notes,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`).run(
|
||||
entry.date, Number(entry.napped), entry.napStart, entry.napDurationMinutes, entry.bedtime,
|
||||
entry.sleepLatencyMinutes, entry.awakenings, entry.wasoMinutes, entry.riseTime,
|
||||
entry.alarmType, entry.rating, entry.notes,
|
||||
);
|
||||
|
||||
return getEntry(entry.date) ?? entry;
|
||||
}
|
||||
18
lib/sleep.test.ts
Normal file
18
lib/sleep.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calculateMetrics, formatDuration } from './sleep';
|
||||
|
||||
describe('sleep calculations', () => {
|
||||
it('calculates overnight time in bed, total sleep, and efficiency', () => {
|
||||
expect(calculateMetrics({
|
||||
bedtime: '23:40',
|
||||
riseTime: '07:10',
|
||||
sleepLatencyMinutes: 30,
|
||||
wasoMinutes: 40,
|
||||
})).toEqual({ timeInBedMinutes: 450, totalSleepMinutes: 380, efficiency: 84 });
|
||||
});
|
||||
|
||||
it('formats a duration for the summary', () => {
|
||||
expect(formatDuration(380)).toBe('6 ч 20 мин');
|
||||
expect(formatDuration(5)).toBe('5 мин');
|
||||
});
|
||||
});
|
||||
58
lib/sleep.ts
Normal file
58
lib/sleep.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export type AlarmType = 'alarm' | 'natural' | 'awakened';
|
||||
|
||||
export interface SleepEntry {
|
||||
date: string;
|
||||
napped: boolean;
|
||||
napStart: string | null;
|
||||
napDurationMinutes: number | null;
|
||||
bedtime: string;
|
||||
sleepLatencyMinutes: number;
|
||||
awakenings: number;
|
||||
wasoMinutes: number;
|
||||
riseTime: string;
|
||||
alarmType: AlarmType;
|
||||
rating: number;
|
||||
notes: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface SleepMetrics {
|
||||
timeInBedMinutes: number;
|
||||
totalSleepMinutes: number;
|
||||
efficiency: number;
|
||||
}
|
||||
|
||||
export function calculateMetrics(entry: Pick<SleepEntry, 'bedtime' | 'riseTime' | 'sleepLatencyMinutes' | 'wasoMinutes'>): SleepMetrics {
|
||||
const [bedHours, bedMinutes] = entry.bedtime.split(':').map(Number);
|
||||
const [riseHours, riseMinutes] = entry.riseTime.split(':').map(Number);
|
||||
let timeInBedMinutes = riseHours * 60 + riseMinutes - (bedHours * 60 + bedMinutes);
|
||||
|
||||
if (timeInBedMinutes <= 0) timeInBedMinutes += 24 * 60;
|
||||
|
||||
const totalSleepMinutes = Math.max(0, timeInBedMinutes - entry.sleepLatencyMinutes - entry.wasoMinutes);
|
||||
const efficiency = timeInBedMinutes === 0 ? 0 : Math.round((totalSleepMinutes / timeInBedMinutes) * 100);
|
||||
|
||||
return { timeInBedMinutes, totalSleepMinutes, efficiency };
|
||||
}
|
||||
|
||||
export function formatDuration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainder = minutes % 60;
|
||||
return hours > 0 ? `${hours} ч ${remainder.toString().padStart(2, '0')} мин` : `${remainder} мин`;
|
||||
}
|
||||
|
||||
export const defaultEntry = (date: string): SleepEntry => ({
|
||||
date,
|
||||
napped: false,
|
||||
napStart: null,
|
||||
napDurationMinutes: null,
|
||||
bedtime: '23:30',
|
||||
sleepLatencyMinutes: 20,
|
||||
awakenings: 0,
|
||||
wasoMinutes: 0,
|
||||
riseTime: '07:00',
|
||||
alarmType: 'natural',
|
||||
rating: 3,
|
||||
notes: '',
|
||||
});
|
||||
7
next-env.d.ts
vendored
Normal file
7
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7794
package-lock.json
generated
Normal file
7794
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "sleepy",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"next": "^16.0.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.5"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#f5f4ed"/><circle cx="256" cy="256" r="168" fill="#141413"/><text x="256" y="322" text-anchor="middle" font-family="Georgia, serif" font-size="200" fill="#f5f4ed">☾</text><circle cx="356" cy="176" r="18" fill="#c96442"/></svg>
|
||||
|
After Width: | Height: | Size: 337 B |
10
public/manifest.webmanifest
Normal file
10
public/manifest.webmanifest
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Sleepy - дневник сна",
|
||||
"short_name": "Sleepy",
|
||||
"description": "Личный дневник сна.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f5f4ed",
|
||||
"theme_color": "#f5f4ed",
|
||||
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }]
|
||||
}
|
||||
17
public/sw.js
Normal file
17
public/sw.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const CACHE = 'sleepy-v1';
|
||||
const ASSETS = ['/', '/manifest.webmanifest', '/favicon.svg'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(ASSETS)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))));
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') return;
|
||||
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((cached) => cached ?? caches.match('/'))));
|
||||
});
|
||||
42
tsconfig.json
Normal file
42
tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es2022"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user