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

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.next
.git
.refs
data
npm-debug.log*

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.next/
data/*.db
data/*.db-*
!.gitkeep
.env
npm-debug.log*

21
AGENTS.md Normal file
View 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
View 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
View 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` и сохраняются между обновлениями контейнера.

View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
import { SleepDiary } from '@/components/SleepDiary';
export default function Home() {
return <SleepDiary />;
}

View 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
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>; }

0
data/.gitkeep Normal file
View File

14
docker-compose.yml Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

1
favicon.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View 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
View 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

View 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
View 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
View 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"
]
}