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

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: '',
});