feat: implement Sleepy sleep diary
This commit is contained in:
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: '',
|
||||
});
|
||||
Reference in New Issue
Block a user