commit a0fa0e0e560b01b0b5691769a15cafd7e92b8804 Author: hexdev Date: Sun Oct 11 12:45:09 2026 +0700 feat: implement Sleepy sleep diary diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..deaa46c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +.refs +data +npm-debug.log* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34cdcd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.next/ +data/*.db +data/*.db-* +!.gitkeep +.env +npm-debug.log* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3b7b972 --- /dev/null +++ b/AGENTS.md @@ -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 находится в корне. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..20e17a1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d29600 --- /dev/null +++ b/README.md @@ -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` и сохраняются между обновлениями контейнера. diff --git a/app/api/entries/[date]/route.ts b/app/api/entries/[date]/route.ts new file mode 100644 index 0000000..ef317db --- /dev/null +++ b/app/api/entries/[date]/route.ts @@ -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) }); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..d7d2020 --- /dev/null +++ b/app/globals.css @@ -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; } } diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..736013f --- /dev/null +++ b/app/layout.tsx @@ -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 {children}; +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..92d8878 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { SleepDiary } from '@/components/SleepDiary'; + +export default function Home() { + return ; +} diff --git a/components/ServiceWorker.tsx b/components/ServiceWorker.tsx new file mode 100644 index 0000000..7f0b431 --- /dev/null +++ b/components/ServiceWorker.tsx @@ -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; +} diff --git a/components/SleepDiary.tsx b/components/SleepDiary.tsx new file mode 100644 index 0000000..0bcaf45 --- /dev/null +++ b/components/SleepDiary.tsx @@ -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(() => 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(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 ( +
+
+

Sleepy

дневник сна

+
+
Дата записи

{dayFormatter.format(new Date(`${date}T12:00:00`))}

+
+

Заполнено: {filled} из 6. Достаточно, чтобы увидеть картину ночи.

+
+
+
+ +
+
+ +
+ {entry.napped &&
update('napStart', event.target.value || null)} /> update('napDurationMinutes', Number(event.target.value) || null)} />
} +
+ +
update('bedtime', event.target.value)} /> update('sleepLatencyMinutes', Number(event.target.value))} />
+
{[5, 15, 30, 60, 90].map((minutes) => )}
+
+ +
{entry.awakenings}раз за ночь
update('wasoMinutes', Number(event.target.value))} />
+
+ +
update('riseTime', event.target.value)} />
+
+ +
{['совсем не спал(а)', 'плохо', 'терпимо', 'хорошо', 'отлично'].map((label, index) => { const value = index + 1; return ; })}
+