diff --git a/CLAUDE.md b/CLAUDE.md index 4a827b5..4535447 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ - **Build**: electron-vite 2 + Vite 5 + electron-builder 25 (NSIS, x64 only) - **UI**: React 18 + TypeScript 5 + Tailwind 3 + framer-motion + react-router (HashRouter) + zustand 5 - **Auto-update**: electron-updater 6, generic provider, фиксированный канал -- **Тесты**: Vitest 4 (203 теста, все зелёные) +- **Тесты**: Vitest 4 (227 тестов, все зелёные) - **Lint/format**: ESLint 8 (flat-ish .eslintrc.cjs) + Prettier 3 + EditorConfig - **Иконки**: lucide-react (whitelisted lookup через `ICON_CHOICES`) - **Шрифты**: Plus Jakarta Sans, Bricolage Grotesque, JetBrains Mono (Google Fonts CDN) @@ -68,6 +68,20 @@ - Wrap-around (22:00 → 07:00) корректно — при wrap-active проверяется день *начала* окна - Тесты в `src/shared/quiet-hours.test.ts` +### Питание / приёмы пищи (вкладка «Питание») +- **Отдельная модель `Meal`** (`src/shared/types.ts`): напоминание ПО ВРЕМЕНИ СУТОК + (`time` HH:MM + `days` weekdays), в отличие от interval-based `Exercise` +- `nextMealOccurrence(time, days, fromMs)` — следующее срабатывание, календарная + арифметика (DST-safe, как history.ts). Тесты в `src/shared/meals.test.ts` +- Scheduler `checkDueMeals()` (`src/main/scheduler.ts`): гейтит **только** + `globalEnabled` (НЕ тихие часы / НЕ ВКС — время задано пользователем явно). + Grace-окно `MEAL_GRACE_MS=120s`: приём, пропущенный давно (сон/выкл), тихо + переносится без срабатывания, чтобы не вывалить пачку напоминаний разом +- Окно напоминания: `evtFireMeal` → `MealReminder` в `ReminderApp.tsx` (зелёный + акцент `bg-success`, кнопки «Поел» / «Отложить») +- Пресеты быстрого добавления — `MEAL_PRESETS` (имена через i18n-ключи) +- Время валидируется строго (`validHHMM` в validate.ts — диапазон, не только форма) + ### История / стрики - `src/renderer/src/lib/history.ts` — DST-safe через `shiftDays()` (calendar `setDate`, не ms-арифметика) - Cap 10k записей, trim oldest 10% на overflow @@ -119,38 +133,41 @@ npm run release -- -Bump patch | `package.json` | version, publish.url, scripts, deps | | `src/main/store.ts` | persistence, migrations, validation, atomic writes | | `src/main/ipc.ts` | IPC handlers с валидацией | -| `src/main/scheduler.ts` | таймеры упражнений, powerMonitor | +| `src/main/scheduler.ts` | таймеры упражнений (interval) + приёмы пищи (clock-time), powerMonitor | | `src/main/games/dota2.ts` + `gsi-server.ts` | GSI приём матчей | | `src/main/updater.ts` | auto-update logic, silent retries | | `src/shared/types.ts` | shared типы, дефолты, isQuietAt | | `src/shared/ipc.ts` | IPC channel types | | `src/renderer/src/i18n/dict.ts` | словари | | `src/renderer/src/pages/Dashboard.tsx` | главная | -| `src/renderer/src/ReminderApp.tsx` | окно напоминания | +| `src/renderer/src/pages/Meals.tsx` + `components/MealEditor.tsx` | вкладка «Питание» | +| `src/renderer/src/ReminderApp.tsx` | окно напоминания (упражнение / еда / матч) | -## Тесты (203) +## Тесты (227) ``` -src/main/validate.test.ts (68) +src/main/validate.test.ts (78) src/renderer/src/lib/history.test.ts (31) src/renderer/src/i18n/i18n.test.ts (15) src/renderer/src/lib/format.test.ts (14) +src/main/scheduler.test.ts (13) ← main: gating + приёмы пищи src/main/games/vdf.test.ts (11) src/main/store.test.ts (10) ← main: миграции/карантин/cap src/renderer/src/lib/achievements.test.ts (10) src/shared/release-notes.test.ts (9) -src/main/scheduler.test.ts (8) ← main: gating-логика +src/shared/meals.test.ts (8) ← nextMealOccurrence (DST-safe) src/main/meeting-detect.test.ts (7) ← main: детект ВКС + кэш/timeout src/shared/quiet-hours.test.ts (7) src/main/adaptive.test.ts (6) src/shared/types.test.ts (4) -src/renderer/src/lib/icon-choices.test.ts (3) +src/renderer/src/lib/icon-choices.test.ts (4) ``` -Покрываются: IPC-валидация, persistence (миграции/карантин/cap), scheduler-gating -(тихие часы/ВКС/daily-goal), детект ВКС (мок child_process), helpers, история/стрики -(DST), тихие часы (wrap+filter), VDF-парсер Steam, достижения, i18n с плюрализацией, -дефолты. +Покрываются: IPC-валидация (упражнения/челленджи/приёмы пищи), persistence +(миграции/карантин/cap), scheduler-gating (тихие часы/ВКС/daily-goal), планирование +приёмов пищи по времени суток (DST-safe, grace-окно, игнор тихих часов), детект ВКС +(мок child_process), helpers, история/стрики (DST), тихие часы (wrap+filter), +VDF-парсер Steam, достижения, i18n с плюрализацией, дефолты. Паттерн для main-тестов: `vi.mock('electron'|'./store'|'node:child_process')` + `vi.resetModules()` + dynamic import (сброс module-level состояния между тестами). diff --git a/README.md b/README.md index 1892c65..86a0973 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Windows desktop приложение, которое напоминает дел ## Что внутри - **Гибкие напоминания** — любое количество упражнений, интервал от минуты до часов, разные иконки. +- **Питание** — отдельная вкладка с приёмами пищи по времени суток (завтрак/обед/ужин/перекусы), выбор дней недели, пресеты быстрого добавления. Напоминания по настенным часам, а не по интервалу. - **История и стрики** — heatmap-календарь активности, ежедневный счётчик, серия дней подряд. - **Тихие часы** — окно времени когда напоминания подавляются (например `22:00 → 08:00`), с выбором дней недели. - **Сделал частично** — степпер `−/+` в окне напоминания: если ты сделал 5 из 10, в историю запишется честное число. @@ -66,25 +67,26 @@ npm run release -- -Bump patch # bump версии + tag + push + upload в G ## Тесты ``` -src/main/validate.test.ts (68) +src/main/validate.test.ts (78) src/renderer/src/lib/history.test.ts (31) src/renderer/src/i18n/i18n.test.ts (15) src/renderer/src/lib/format.test.ts (14) +src/main/scheduler.test.ts (13) src/main/games/vdf.test.ts (11) src/main/store.test.ts (10) src/renderer/src/lib/achievements.test.ts (10) src/shared/release-notes.test.ts (9) -src/main/scheduler.test.ts (8) +src/shared/meals.test.ts (8) src/main/meeting-detect.test.ts (7) src/shared/quiet-hours.test.ts (7) src/main/adaptive.test.ts (6) src/shared/types.test.ts (4) -src/renderer/src/lib/icon-choices.test.ts (3) +src/renderer/src/lib/icon-choices.test.ts (4) ────────────────────────────────────────── - 203 ✓ + 227 ✓ ``` -Покрытие: IPC-валидация, persistence (миграции, карантин битого JSON, history cap), scheduler-гейтинг (тихие часы, ВКС-пауза, daily-goal), детект ВКС, история/стрики (DST), тихие часы (wrap), парсер VDF для Steam-конфигов, достижения, i18n с плюрализацией RU/EN, дефолты shared-типов. +Покрытие: IPC-валидация (упражнения/челленджи/приёмы пищи), persistence (миграции, карантин битого JSON, history cap), scheduler-гейтинг (тихие часы, ВКС-пауза, daily-goal), планирование приёмов пищи по времени суток (DST-safe), детект ВКС, история/стрики (DST), тихие часы (wrap), парсер VDF для Steam-конфигов, достижения, i18n с плюрализацией RU/EN, дефолты shared-типов. ## Лицензия diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9176034..40873ae 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -14,9 +14,11 @@ import type { Exercise, GameId, Settings } from '@shared/types' import { addChallenge, addExercise, + addMeal, clearHistory, deleteChallenge, deleteExercise, + deleteMeal, exportState, getHistory, getState, @@ -24,11 +26,13 @@ import { importState, markChallengeDone, markDone, + markMealDone, setGameEnabled, skip, snooze, updateChallenge, updateExercise, + updateMeal, updateSettings } from './store' import { broadcastHistoryChanged, broadcastState } from './state-actions' @@ -58,6 +62,8 @@ import { validateExerciseInput, validateExercisePatch, validateId, + validateMealInput, + validateMealPatch, validateSettingsPatch, validateSnoozeMinutes } from './validate' @@ -194,6 +200,48 @@ export function registerIpc(): void { return ex }) + // Meals (приёмы пищи — напоминания по времени суток) + safeHandle(IPC.addMeal, (_e, input: unknown) => { + const safe = validateMealInput(input) + if (!safe) return null + const m = addMeal(safe) + broadcastState() + return m + }) + + safeHandle(IPC.updateMeal, (_e, idRaw: unknown, patchRaw: unknown) => { + const id = validateId(idRaw) + const patch = validateMealPatch(patchRaw) + if (!id || !patch) return null + const m = updateMeal(id, patch) + broadcastState() + return m + }) + + safeHandle(IPC.deleteMeal, (_e, idRaw: unknown) => { + const id = validateId(idRaw) + if (!id) return false + const ok = deleteMeal(id) + broadcastState() + return ok + }) + + safeHandle(IPC.toggleMeal, (_e, idRaw: unknown, enabledRaw: unknown) => { + const id = validateId(idRaw) + if (!id || typeof enabledRaw !== 'boolean') return null + const m = updateMeal(id, { enabled: enabledRaw }) + broadcastState() + return m + }) + + safeHandle(IPC.markMealDone, (_e, idRaw: unknown) => { + const id = validateId(idRaw) + if (!id) return null + const m = markMealDone(id) + broadcastState() + return m + }) + safeHandle(IPC.updateSettings, (_e, patchRaw: unknown) => { const patch = validateSettingsPatch(patchRaw) if (!patch) return null diff --git a/src/main/notifications.ts b/src/main/notifications.ts index f2ff7ec..48fcb13 100644 --- a/src/main/notifications.ts +++ b/src/main/notifications.ts @@ -1,5 +1,10 @@ import { Notification, app } from 'electron' -import type { Exercise, MatchSummary, NotificationMode } from '@shared/types' +import type { + Exercise, + MatchSummary, + Meal, + NotificationMode +} from '@shared/types' import { IPC } from '@shared/ipc' import { createReminderWindow, @@ -12,6 +17,35 @@ export function fireReminder(exercise: Exercise, mode: NotificationMode): void { if (mode === 'modal' || mode === 'both') showModal(exercise) } +export function fireMealReminder(meal: Meal, mode: NotificationMode): void { + if (mode === 'toast' || mode === 'both') showMealToast(meal) + if (mode === 'modal' || mode === 'both') showMealModal(meal) +} + +function showMealToast(meal: Meal): void { + if (!Notification.isSupported()) return + const n = new Notification({ + title: app.getName(), + body: meal.name, + silent: false + }) + n.on('click', () => showReminderWindow()) + n.show() +} + +function showMealModal(meal: Meal): void { + const win = createReminderWindow() + const send = (): void => { + win.webContents.send(IPC.evtFireMeal, meal) + } + if (win.webContents.isLoading()) { + win.webContents.once('did-finish-load', send) + } else { + send() + } + showReminderWindow() +} + export function fireMatchSummary(summary: MatchSummary): void { if (Notification.isSupported()) { const totalReps = summary.results.reduce((s, r) => s + r.reps, 0) diff --git a/src/main/scheduler.test.ts b/src/main/scheduler.test.ts index 1326c04..7b18b3b 100644 --- a/src/main/scheduler.test.ts +++ b/src/main/scheduler.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Exercise, HistoryEntry, + Meal, QuietHours, Settings } from '@shared/types' @@ -16,10 +17,13 @@ import { DEFAULT_SETTINGS } from '@shared/types' const h = vi.hoisted(() => ({ settings: null as Settings | null, exercises: [] as Exercise[], + meals: [] as Meal[], history: [] as HistoryEntry[], meetingActive: false, fireReminder: vi.fn(), + fireMealReminder: vi.fn(), updateExercise: vi.fn(), + updateMeal: vi.fn(), broadcastState: vi.fn(), refreshMeetingState: vi.fn(), adjustNextFireAt: vi.fn((_ex: Exercise, candidate: number) => candidate) @@ -32,14 +36,23 @@ vi.mock('electron', () => ({ vi.mock('./store', () => ({ getSettings: () => h.settings, getExercises: () => h.exercises, + getMeals: () => h.meals, getHistory: () => h.history, updateExercise: (id: string, patch: Partial) => { h.updateExercise(id, patch) const ex = h.exercises.find((e) => e.id === id) return ex ? { ...ex, ...patch } : undefined + }, + updateMeal: (id: string, patch: Partial) => { + h.updateMeal(id, patch) + const m = h.meals.find((e) => e.id === id) + return m ? { ...m, ...patch } : undefined } })) -vi.mock('./notifications', () => ({ fireReminder: h.fireReminder })) +vi.mock('./notifications', () => ({ + fireReminder: h.fireReminder, + fireMealReminder: h.fireMealReminder +})) vi.mock('./state-actions', () => ({ broadcastState: h.broadcastState })) vi.mock('./meeting-detect', () => ({ isMeetingActiveSync: () => h.meetingActive, @@ -78,14 +91,30 @@ async function loadScheduler(): Promise { return import('./scheduler') } +function makeMeal(over: Partial = {}): Meal { + return { + id: 'm1', + name: 'Обед', + time: '13:00', + icon: 'Soup', + enabled: true, + days: [], + nextFireAt: Date.now() - 1000, // due, в пределах grace + ...over + } +} + beforeEach(() => { vi.resetModules() h.settings = { ...DEFAULT_SETTINGS } h.exercises = [] + h.meals = [] h.history = [] h.meetingActive = false h.fireReminder.mockClear() + h.fireMealReminder.mockClear() h.updateExercise.mockClear() + h.updateMeal.mockClear() h.broadcastState.mockClear() h.refreshMeetingState.mockClear() h.adjustNextFireAt.mockClear() @@ -165,3 +194,49 @@ describe('checkDueExercises gating', () => { expect(h.fireReminder).toHaveBeenCalledTimes(1) }) }) + +describe('checkDueMeals', () => { + it('fire-ит приём пищи, чьё время наступило (в пределах grace)', async () => { + h.meals = [makeMeal()] + const { forceCheck } = await loadScheduler() + forceCheck() + expect(h.fireMealReminder).toHaveBeenCalledTimes(1) + // Переносит nextFireAt вперёд. + expect(h.updateMeal).toHaveBeenCalled() + }) + + it('пропускает выключенный приём пищи', async () => { + h.meals = [makeMeal({ enabled: false })] + const { forceCheck } = await loadScheduler() + forceCheck() + expect(h.fireMealReminder).not.toHaveBeenCalled() + }) + + it('не fire-ит при globalEnabled=false', async () => { + h.settings = { ...DEFAULT_SETTINGS, globalEnabled: false } + h.meals = [makeMeal()] + const { forceCheck } = await loadScheduler() + forceCheck() + expect(h.fireMealReminder).not.toHaveBeenCalled() + }) + + it('пропущенный давно (> grace) переносится без срабатывания', async () => { + h.meals = [makeMeal({ nextFireAt: Date.now() - 10 * 60_000 })] + const { forceCheck } = await loadScheduler() + forceCheck() + expect(h.fireMealReminder).not.toHaveBeenCalled() + expect(h.updateMeal).toHaveBeenCalled() // всё равно переносим вперёд + }) + + it('приёмы пищи ИГНОРИРУЮТ тихие часы (в отличие от упражнений)', async () => { + h.settings = { ...DEFAULT_SETTINGS, quietHours: quietWindowAroundNow() } + h.exercises = [makeExercise()] + h.meals = [makeMeal()] + const { forceCheck } = await loadScheduler() + forceCheck() + // Упражнение подавлено тихими часами... + expect(h.fireReminder).not.toHaveBeenCalled() + // ...а приём пищи всё равно срабатывает. + expect(h.fireMealReminder).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/scheduler.ts b/src/main/scheduler.ts index ffdce84..10460fc 100644 --- a/src/main/scheduler.ts +++ b/src/main/scheduler.ts @@ -1,9 +1,16 @@ import { powerMonitor, BrowserWindow } from 'electron' import { IPC } from '@shared/ipc' import type { Exercise, Tick, HistoryEntry } from '@shared/types' -import { isQuietAt } from '@shared/types' -import { getExercises, getHistory, getSettings, updateExercise } from './store' -import { fireReminder } from './notifications' +import { isQuietAt, nextMealOccurrence } from '@shared/types' +import { + getExercises, + getHistory, + getMeals, + getSettings, + updateExercise, + updateMeal +} from './store' +import { fireMealReminder, fireReminder } from './notifications' import { broadcastState } from './state-actions' import { isMeetingActiveSync, refreshMeetingState } from './meeting-detect' import { adjustNextFireAt } from './adaptive' @@ -95,6 +102,39 @@ function checkDueExercises(): void { if (anyFired) broadcastState() } +/** + * Окно «опоздания»: приём пищи, чьё время прошло более чем на это, считаем + * пропущенным (ноут спал / приложение было выключено) и тихо переносим на + * следующее вхождение БЕЗ срабатывания — чтобы не вывалить пачку + * напоминаний разом при включении вечером. Чуть больше CHECK_MS с запасом. + */ +const MEAL_GRACE_MS = 120_000 + +/** + * Приёмы пищи — по времени суток. В отличие от упражнений, НЕ подчиняются + * тихим часам и ВКС-паузе: пользователь явно задал время. Гейтит только + * глобальная пауза (globalEnabled). Срабатывает в пределах grace-окна после + * запланированного времени; в любом случае переносит nextFireAt вперёд. + */ +function checkDueMeals(): void { + const settings = getSettings() + if (!settings.globalEnabled) return + const now = Date.now() + let anyChanged = false + for (const meal of getMeals()) { + if (!meal.enabled) continue + if (meal.nextFireAt > now) continue + if (now - meal.nextFireAt <= MEAL_GRACE_MS) { + fireMealReminder(meal, settings.notificationMode) + } + updateMeal(meal.id, { + nextFireAt: nextMealOccurrence(meal.time, meal.days, now) + }) + anyChanged = true + } + if (anyChanged) broadcastState() +} + function broadcastTicks(): void { const now = Date.now() const ticks: Tick[] = getExercises().map((e) => ({ @@ -113,6 +153,7 @@ function tick(): void { if (now - lastCheckAt >= CHECK_MS) { lastCheckAt = now checkDueExercises() + checkDueMeals() } } diff --git a/src/main/store.ts b/src/main/store.ts index 9e1c9c8..6845c9d 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -17,8 +17,11 @@ import { GameId, HistoryAction, HistoryEntry, + Meal, + nextMealOccurrence, PersistedState, SAMPLE_EXERCISES, + SAMPLE_MEALS, Settings } from '@shared/types' import { log } from './logger' @@ -53,6 +56,11 @@ function makeInitial(): PersistedState { id: randomUUID(), nextFireAt: now + e.intervalMinutes * 60_000 })), + meals: SAMPLE_MEALS.map((m) => ({ + ...m, + id: randomUUID(), + nextFireAt: nextMealOccurrence(m.time, m.days, now) + })), settings: { ...DEFAULT_SETTINGS }, challenges: [ { @@ -149,6 +157,9 @@ function runMigrations(s: StoredState): StoredState { function coerce(s: StoredState): PersistedState { return { exercises: Array.isArray(s.exercises) ? (s.exercises as Exercise[]) : [], + // Additive: старые state'ы без `meals` получают пустой список (см. философию + // миграций — additive-поля не требуют bump'а схемы). + meals: Array.isArray(s.meals) ? (s.meals as Meal[]) : [], settings: { ...DEFAULT_SETTINGS, ...(isValidParsed(s.settings) ? (s.settings as Partial) : {}) @@ -360,6 +371,7 @@ export function getStateForRenderer(): AppState { const p = getState() return { exercises: p.exercises, + meals: p.meals, settings: p.settings, challenges: p.challenges, gamesEnabled: p.gamesEnabled @@ -467,6 +479,74 @@ export function skip(id: string): Exercise | undefined { return ex } +// ------------------------------------------------------------------------- +// Meals (приёмы пищи — по времени суток) +// ------------------------------------------------------------------------- + +export function getMeals(): Meal[] { + return getState().meals +} + +export function addMeal( + input: Omit +): Meal { + const state = getState() + const meal: Meal = { + ...input, + id: randomUUID(), + nextFireAt: nextMealOccurrence(input.time, input.days, Date.now()) + } + state.meals.push(meal) + scheduleWrite() + return meal +} + +export function updateMeal( + id: string, + patch: Partial> +): Meal | undefined { + const state = getState() + const idx = state.meals.findIndex((m) => m.id === id) + if (idx === -1) return undefined + const merged: Meal = { ...state.meals[idx], ...patch } + // Если поменялось время/дни/вкл — и nextFireAt не задан явно — пересчитать + // следующее срабатывание (toggle-on тоже сюда попадает). + if ( + (patch.time !== undefined || + patch.days !== undefined || + patch.enabled !== undefined) && + patch.nextFireAt === undefined + ) { + merged.nextFireAt = nextMealOccurrence(merged.time, merged.days, Date.now()) + } + state.meals[idx] = merged + scheduleWrite() + return merged +} + +export function deleteMeal(id: string): boolean { + const state = getState() + const before = state.meals.length + state.meals = state.meals.filter((m) => m.id !== id) + const ok = state.meals.length < before + if (ok) scheduleWrite() + return ok +} + +export function markMealDone(id: string): Meal | undefined { + const state = getState() + const meal = state.meals.find((m) => m.id === id) + if (!meal) return undefined + meal.lastDoneAt = Date.now() + // nextFireAt обычно уже перенесён планировщиком в момент срабатывания; + // подстраховка на случай ручного вызова — гарантируем будущее время. + if (meal.nextFireAt <= Date.now()) { + meal.nextFireAt = nextMealOccurrence(meal.time, meal.days, Date.now()) + } + scheduleWrite() + return meal +} + /** * Записать выполнение челленджа из match summary в историю. Не привязано * к конкретному Exercise (челлендж может ссылаться на упражнение, которое diff --git a/src/main/validate.test.ts b/src/main/validate.test.ts index 0a3a326..c05d636 100644 --- a/src/main/validate.test.ts +++ b/src/main/validate.test.ts @@ -18,12 +18,90 @@ import { validateExercisePatch, validateChallengeInput, validateChallengePatch, + validateMealInput, + validateMealPatch, validateSettingsPatch, validateId, validateActualReps, validateSnoozeMinutes } from './validate' +describe('validateMealInput', () => { + it('принимает валидный приём пищи', () => { + const r = validateMealInput({ + name: 'Обед', + time: '13:00', + icon: 'Soup', + enabled: true, + days: [1, 2, 3, 4, 5] + }) + expect(r).toEqual({ + name: 'Обед', + time: '13:00', + icon: 'Soup', + enabled: true, + days: [1, 2, 3, 4, 5] + }) + }) + + it('дефолтит icon и enabled', () => { + const r = validateMealInput({ name: 'Ужин', time: '19:00', days: [] }) + expect(r?.icon).toBe('UtensilsCrossed') + expect(r?.enabled).toBe(true) + }) + + it('реджектит без имени / времени', () => { + expect(validateMealInput({ time: '13:00', days: [] })).toBeNull() + expect(validateMealInput({ name: 'X', days: [] })).toBeNull() + }) + + it('реджектит кривое время', () => { + expect( + validateMealInput({ name: 'X', time: '99:99', days: [] }) + ).toBeNull() + expect( + validateMealInput({ name: 'X', time: 'noon', days: [] }) + ).toBeNull() + }) + + it('реджектит дни вне диапазона и дедупит', () => { + expect( + validateMealInput({ name: 'X', time: '13:00', days: [7] }) + ).toBeNull() + const r = validateMealInput({ + name: 'X', + time: '13:00', + days: [1, 1, 2] + }) + expect(r?.days).toEqual([1, 2]) + }) + + it('реджектит не-объект', () => { + expect(validateMealInput(null)).toBeNull() + expect(validateMealInput('meal')).toBeNull() + }) +}) + +describe('validateMealPatch', () => { + it('частичный патч только заданных полей', () => { + expect(validateMealPatch({ time: '07:30' })).toEqual({ time: '07:30' }) + expect(validateMealPatch({ enabled: false })).toEqual({ enabled: false }) + }) + + it('реджектит кривое время в патче', () => { + expect(validateMealPatch({ time: '25:00' })).toBeNull() + }) + + it('пропускает scheduler-поля с range-check', () => { + expect(validateMealPatch({ nextFireAt: 123 })).toEqual({ nextFireAt: 123 }) + expect(validateMealPatch({ nextFireAt: -1 })).toBeNull() + }) + + it('реджектит кривые дни', () => { + expect(validateMealPatch({ days: [0, 8] })).toBeNull() + }) +}) + const validExercise = { name: 'Push-ups', reps: 10, diff --git a/src/main/validate.ts b/src/main/validate.ts index 2b9283d..93f4085 100644 --- a/src/main/validate.ts +++ b/src/main/validate.ts @@ -15,6 +15,7 @@ import type { Challenge, Exercise, GameStat, + Meal, Settings, Theme, Language, @@ -78,6 +79,34 @@ function oneOf( : undefined } +/** + * Строгая проверка "HH:MM": не только форма, но и диапазон (часы 0..23, + * минуты 0..59). В отличие от HHMM_RE (используется в quietHours лишь для + * формы) — приём пищи с временем '25:00' сломал бы nextMealOccurrence. + */ +function validHHMM(v: unknown): string | undefined { + const s = safeStr(v, 8) + if (s === undefined) return undefined + const m = /^(\d{1,2}):(\d{2})$/.exec(s) + if (!m) return undefined + const h = Number(m[1]) + const min = Number(m[2]) + if (h > 23 || min > 59) return undefined + return s +} + +/** Дни недели: массив целых 0..6 без дубликатов. null = невалидно. */ +function weekdays(v: unknown): number[] | null { + if (!Array.isArray(v)) return null + const out: number[] = [] + for (const d of v) { + const n = intInRange(d, 0, 6) + if (n === undefined) return null + if (!out.includes(n)) out.push(n) + } + return out +} + // ----------------------------------------------------------------------- // Exercise validators // ----------------------------------------------------------------------- @@ -188,6 +217,69 @@ export function validateExercisePatch( return out } +// ----------------------------------------------------------------------- +// Meal validators (приёмы пищи — по времени суток) +// ----------------------------------------------------------------------- + +export function validateMealInput( + raw: unknown +): Omit | null { + if (!isObj(raw)) return null + const name = safeStr(raw.name) + const time = validHHMM(raw.time) + const icon = safeStr(raw.icon, 64) ?? 'UtensilsCrossed' + const enabled = bool(raw.enabled) ?? true + const days = weekdays(raw.days) + if (name === undefined || time === undefined || days === null) { + return null + } + return { name, time, icon, enabled, days } +} + +export function validateMealPatch( + raw: unknown +): Partial> | null { + if (!isObj(raw)) return null + const out: Partial> = {} + if ('name' in raw) { + const v = safeStr(raw.name) + if (v === undefined) return null + out.name = v + } + if ('time' in raw) { + const v = validHHMM(raw.time) + if (v === undefined) return null + out.time = v + } + if ('icon' in raw) { + const v = safeStr(raw.icon, 64) + if (v === undefined) return null + out.icon = v + } + if ('enabled' in raw) { + const v = bool(raw.enabled) + if (v === undefined) return null + out.enabled = v + } + if ('days' in raw) { + const v = weekdays(raw.days) + if (v === null) return null + out.days = v + } + // Scheduler-controlled fields (store reschedules через тот же boundary). + if ('nextFireAt' in raw) { + const v = numInRange(raw.nextFireAt, 0, Number.MAX_SAFE_INTEGER) + if (v === undefined) return null + out.nextFireAt = v + } + if ('lastDoneAt' in raw) { + const v = numInRange(raw.lastDoneAt, 0, Number.MAX_SAFE_INTEGER) + if (v === undefined) return null + out.lastDoneAt = v + } + return out +} + // ----------------------------------------------------------------------- // Challenge validators // ----------------------------------------------------------------------- diff --git a/src/preload/index.ts b/src/preload/index.ts index 1422656..3e83e20 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,6 +8,7 @@ import type { GameStatus, HistoryEntry, MatchSummary, + Meal, Settings, Tick, UpdaterStatus @@ -41,6 +42,19 @@ const api = { ipcRenderer.invoke(IPC.snooze, id, minutes), skip: (id: string): Promise => ipcRenderer.invoke(IPC.skip, id), + // Meals + addMeal: ( + input: Omit + ): Promise => ipcRenderer.invoke(IPC.addMeal, input), + updateMeal: (id: string, patch: Partial): Promise => + ipcRenderer.invoke(IPC.updateMeal, id, patch), + deleteMeal: (id: string): Promise => + ipcRenderer.invoke(IPC.deleteMeal, id), + toggleMeal: (id: string, enabled: boolean): Promise => + ipcRenderer.invoke(IPC.toggleMeal, id, enabled), + markMealDone: (id: string): Promise => + ipcRenderer.invoke(IPC.markMealDone, id), + updateSettings: (patch: Partial): Promise => ipcRenderer.invoke(IPC.updateSettings, patch), @@ -136,6 +150,7 @@ const api = { onTick: (h: Handler): Unsub => on(IPC.evtTick, h), onFire: (h: Handler): Unsub => on(IPC.evtFire, h), + onFireMeal: (h: Handler): Unsub => on(IPC.evtFireMeal, h), onMatchEnd: (h: Handler): Unsub => on(IPC.evtMatchEnd, h), onStateChanged: (h: Handler): Unsub => on(IPC.evtStateChanged, h), onThemeChanged: (h: Handler<'light' | 'dark'>): Unsub => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cce308d..728cd0c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import { Skeleton } from './components/ui/Skeleton' import { unseenVersions } from '@shared/release-notes' import Dashboard from './pages/Dashboard' import Exercises from './pages/Exercises' +import Meals from './pages/Meals' import GamesPage from './pages/Games' import ChallengesPage from './pages/Challenges' import SettingsPage from './pages/Settings' @@ -152,6 +153,7 @@ function RoutedPages({ onNav }: { onNav: () => void }): JSX.Element { } /> } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/ReminderApp.tsx b/src/renderer/src/ReminderApp.tsx index 90d160e..d83528b 100644 --- a/src/renderer/src/ReminderApp.tsx +++ b/src/renderer/src/ReminderApp.tsx @@ -13,6 +13,7 @@ import { import type { Exercise, MatchSummary, + Meal, Settings, ChallengeResult, Language @@ -26,6 +27,7 @@ import { translate, translateN } from './i18n' type Mode = | { kind: 'idle' } | { kind: 'exercise'; exercise: Exercise } + | { kind: 'meal'; meal: Meal } | { kind: 'match'; summary: MatchSummary; done: Set } /** Минимальный нативный confirm. В reminder-окне нет места для модалки, @@ -69,6 +71,17 @@ export default function ReminderApp(): JSX.Element { }, 800) } }) + const u1b = window.api.onFireMeal((meal) => { + setMode({ kind: 'meal', meal }) + const s = settingsRef.current + if (s?.soundEnabled) playBeep() + if (s?.voicePromptsEnabled) { + const lang = s.language ?? 'ru' + const phrase = + lang === 'ru' ? `Пора поесть. ${meal.name}` : `Time to eat. ${meal.name}` + speak(phrase, lang) + } + }) const u2 = window.api.onMatchEnd((summary) => { // Новый матч — сбрасываем дедуп challenge'ей. sentChallengesRef.current = new Set() @@ -88,6 +101,7 @@ export default function ReminderApp(): JSX.Element { return () => { u0() u1() + u1b() u2() } }, []) @@ -145,6 +159,17 @@ export default function ReminderApp(): JSX.Element { /> ) } + if (mode.kind === 'meal') { + return ( + + ) + } return ( void +}): JSX.Element { + const t = (key: string, vars?: Record): string => + translate(lang, key, vars) + + async function done(): Promise { + await window.api.markMealDone(meal.id) + onClose() + } + async function snooze(): Promise { + // «Отложить» = напомнить снова через snoozeMinutes (перетираем + // запланированный планировщиком nextFireAt на завтра). + await window.api.updateMeal(meal.id, { + nextFireAt: Date.now() + snoozeMinutes * 60_000 + }) + onClose() + } + + useEffect(() => { + function onKey(e: KeyboardEvent): void { + const targetTag = (e.target as HTMLElement | null)?.tagName + if (e.key === 'Enter') { + e.preventDefault() + void done() + } else if (e.key === 'Escape') { + e.preventDefault() + onClose() + } else if ((e.key === ' ' || e.code === 'Space') && targetTag !== 'BUTTON') { + e.preventDefault() + void snooze() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [snoozeMinutes]) + + return ( +
+
+ +
+ +
+ +
+ +
+
+ +
+ {t('meal.cta')} +
+

+ {meal.name} +

+
+ {meal.time} +
+
+ +
+ + +
+
+ ) +} + function MatchSummaryView({ summary, done, diff --git a/src/renderer/src/components/MealEditor.tsx b/src/renderer/src/components/MealEditor.tsx new file mode 100644 index 0000000..052583a --- /dev/null +++ b/src/renderer/src/components/MealEditor.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from 'react' +import type { Meal } from '@shared/types' +import { Modal } from './ui/Modal' +import { Button } from './ui/Button' +import { ICON_CHOICES, Icon } from '../lib/icon' +import { useT } from '../i18n' + +export type MealDraft = { + name: string + time: string + icon: string + enabled: boolean + days: number[] +} + +const EMPTY: MealDraft = { + name: '', + time: '13:00', + icon: 'UtensilsCrossed', + enabled: true, + days: [] +} + +// Понедельник-первый порядок для UI; значения — индексы getDay() (0=Вс). +const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] + +type Props = { + open: boolean + meal?: Meal | null + onClose: () => void + onSave: (draft: MealDraft) => void +} + +export function MealEditor({ + open, + meal, + onClose, + onSave +}: Props): JSX.Element { + const [draft, setDraft] = useState(EMPTY) + const { t } = useT() + + useEffect(() => { + if (meal) { + setDraft({ + name: meal.name, + time: meal.time, + icon: meal.icon, + enabled: meal.enabled, + days: meal.days + }) + } else { + setDraft(EMPTY) + } + }, [meal, open]) + + const canSave = + draft.name.trim().length > 0 && /^\d{1,2}:\d{2}$/.test(draft.time) + const weekdayLabels = t('meals.weekdays').split(',') + + function toggleDay(dow: number): void { + setDraft((d) => ({ + ...d, + days: d.days.includes(dow) + ? d.days.filter((x) => x !== dow) + : [...d.days, dow] + })) + } + + return ( + + + + + } + > +
+
+
+ +
+
+
+ {draft.name || t('editor.meal.preview.placeholder')} +
+
+ {draft.time} +
+
+
+ + + setDraft({ ...draft, name: e.target.value })} + placeholder={t('editor.meal.name.placeholder')} + className="ios-input" + autoFocus + /> + + + + setDraft({ ...draft, time: e.target.value })} + className="ios-input font-mono-num" + /> + + + +
+ {WEEKDAY_ORDER.map((dow) => { + const active = draft.days.includes(dow) + return ( + + ) + })} +
+
+ {t('editor.meal.field.days.hint')} +
+
+ + +
+ {ICON_CHOICES.map((name) => ( + + ))} +
+
+
+ + +
+ ) +} + +function Field({ + label, + children +}: { + label: string + children: React.ReactNode +}): JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 5260c54..762c257 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -1,7 +1,15 @@ import { useEffect, useRef } from 'react' import { NavLink } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' -import { Sun, Dumbbell, Joystick, Flame, Settings2, X } from 'lucide-react' +import { + Sun, + Dumbbell, + UtensilsCrossed, + Joystick, + Flame, + Settings2, + X +} from 'lucide-react' import { useT } from '../i18n' type Item = { @@ -20,6 +28,12 @@ const items: Item[] = [ icon: Dumbbell, tint: 'bg-info' }, + { + to: '/meals', + labelKey: 'nav.meals', + icon: UtensilsCrossed, + tint: 'bg-success' + }, { to: '/games', labelKey: 'nav.games', icon: Joystick, tint: 'bg-accent-2' }, { to: '/challenges', diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index 7a2b9b3..b36973b 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -14,6 +14,7 @@ export const ru: Dict = { // Sidebar / nav 'nav.today': 'Сегодня', 'nav.exercises': 'Упражнения', + 'nav.meals': 'Питание', 'nav.games': 'Игры', 'nav.challenges': 'Челленджи', 'nav.settings': 'Настройки', @@ -95,6 +96,33 @@ export const ru: Dict = { 'exercises.row.meta': '{reps} раз · {interval}', 'exercises.empty': 'Программа пуста — добавь первое упражнение', + // Meals (приёмы пищи) + 'meals.kicker': 'Режим питания', + 'meals.title': 'Питание', + 'meals.presets': 'Быстрое добавление', + 'meals.section.active': 'Активные · {n}', + 'meals.section.disabled': 'Выключенные · {n}', + 'meals.empty': 'Пока нет приёмов пищи — добавь первый или выбери пресет', + 'meals.everyday': 'ежедневно', + 'meals.weekdays': 'Вс,Пн,Вт,Ср,Чт,Пт,Сб', + 'meals.preset.breakfast': 'Завтрак', + 'meals.preset.lunch': 'Обед', + 'meals.preset.dinner': 'Ужин', + 'meals.preset.snack': 'Перекус', + + // Meal editor + 'editor.meal.title.new': 'Новый приём пищи', + 'editor.meal.title.edit': 'Изменить приём пищи', + 'editor.meal.preview.placeholder': 'Без названия', + 'editor.meal.name.placeholder': 'Например, Обед', + 'editor.meal.field.time': 'Время', + 'editor.meal.field.days': 'Дни недели', + 'editor.meal.field.days.hint': 'Ничего не выбрано — напоминаем каждый день', + + // Meal reminder window + 'meal.cta': 'Пора поесть', + 'meal.btn.ate': 'Поел', + // Exercise editor 'editor.exercise.title.new': 'Новое упражнение', 'editor.exercise.title.edit': 'Редактировать', @@ -347,6 +375,7 @@ export const en: Dict = { // Sidebar / nav 'nav.today': 'Today', 'nav.exercises': 'Exercises', + 'nav.meals': 'Meals', 'nav.games': 'Games', 'nav.challenges': 'Challenges', 'nav.settings': 'Settings', @@ -427,6 +456,33 @@ export const en: Dict = { 'exercises.row.meta': '{reps} reps · {interval}', 'exercises.empty': 'Program is empty — add your first exercise', + // Meals + 'meals.kicker': 'Eating schedule', + 'meals.title': 'Meals', + 'meals.presets': 'Quick add', + 'meals.section.active': 'Active · {n}', + 'meals.section.disabled': 'Disabled · {n}', + 'meals.empty': 'No meals yet — add one or pick a preset', + 'meals.everyday': 'every day', + 'meals.weekdays': 'Sun,Mon,Tue,Wed,Thu,Fri,Sat', + 'meals.preset.breakfast': 'Breakfast', + 'meals.preset.lunch': 'Lunch', + 'meals.preset.dinner': 'Dinner', + 'meals.preset.snack': 'Snack', + + // Meal editor + 'editor.meal.title.new': 'New meal', + 'editor.meal.title.edit': 'Edit meal', + 'editor.meal.preview.placeholder': 'Untitled', + 'editor.meal.name.placeholder': 'e.g. Lunch', + 'editor.meal.field.time': 'Time', + 'editor.meal.field.days': 'Days of week', + 'editor.meal.field.days.hint': 'None selected — reminds every day', + + // Meal reminder window + 'meal.cta': 'Time to eat', + 'meal.btn.ate': 'Ate it', + // Exercise editor 'editor.exercise.title.new': 'New exercise', 'editor.exercise.title.edit': 'Edit', diff --git a/src/renderer/src/lib/icon-choices.test.ts b/src/renderer/src/lib/icon-choices.test.ts index 824d8a9..703af8b 100644 --- a/src/renderer/src/lib/icon-choices.test.ts +++ b/src/renderer/src/lib/icon-choices.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { ICON_CHOICES } from './icon-choices' -import { SAMPLE_EXERCISES } from '@shared/types' +import { MEAL_PRESETS, SAMPLE_EXERCISES, SAMPLE_MEALS } from '@shared/types' describe('ICON_CHOICES', () => { // Если иконка SAMPLE_EXERCISES не входит в whitelist, при первом запуске @@ -16,6 +16,22 @@ describe('ICON_CHOICES', () => { } }) + it('contains every icon used by SAMPLE_MEALS and MEAL_PRESETS', () => { + const allowed = new Set(ICON_CHOICES) + for (const m of SAMPLE_MEALS) { + expect( + allowed.has(m.icon), + `icon "${m.icon}" for meal "${m.name}" is not in ICON_CHOICES` + ).toBe(true) + } + for (const p of MEAL_PRESETS) { + expect( + allowed.has(p.icon), + `icon "${p.icon}" for preset "${p.nameKey}" is not in ICON_CHOICES` + ).toBe(true) + } + }) + it('has no duplicates', () => { expect(new Set(ICON_CHOICES).size).toBe(ICON_CHOICES.length) }) diff --git a/src/renderer/src/lib/icon-choices.ts b/src/renderer/src/lib/icon-choices.ts index ab64d7b..91a753f 100644 --- a/src/renderer/src/lib/icon-choices.ts +++ b/src/renderer/src/lib/icon-choices.ts @@ -21,7 +21,9 @@ export const ICON_CHOICES = [ 'Apple', 'GlassWater', 'BookOpen', - 'Sparkles' + 'Sparkles', + 'UtensilsCrossed', + 'Soup' ] as const export type IconName = (typeof ICON_CHOICES)[number] diff --git a/src/renderer/src/lib/icon.tsx b/src/renderer/src/lib/icon.tsx index 3ac5d25..1569cc1 100644 --- a/src/renderer/src/lib/icon.tsx +++ b/src/renderer/src/lib/icon.tsx @@ -19,7 +19,9 @@ import { Apple, GlassWater, BookOpen, - Sparkles + Sparkles, + UtensilsCrossed, + Soup } from 'lucide-react' import type { LucideProps } from 'lucide-react' import { ICON_CHOICES, type IconName } from './icon-choices' @@ -44,7 +46,9 @@ const ICON_MAP: Record> = { Apple, GlassWater, BookOpen, - Sparkles + Sparkles, + UtensilsCrossed, + Soup } /** diff --git a/src/renderer/src/pages/Meals.tsx b/src/renderer/src/pages/Meals.tsx new file mode 100644 index 0000000..72c635d --- /dev/null +++ b/src/renderer/src/pages/Meals.tsx @@ -0,0 +1,201 @@ +import { useState } from 'react' +import { Plus, ChevronRight, UtensilsCrossed } from 'lucide-react' +import { useAppStore } from '../store/appStore' +import { MealEditor, type MealDraft } from '../components/MealEditor' +import { Button } from '../components/ui/Button' +import { Switch } from '../components/ui/Switch' +import { Card, Row, SectionHeader } from '../components/ui/Card' +import { Icon } from '../lib/icon' +import { useT } from '../i18n' +import { MEAL_PRESETS, type Meal } from '@shared/types' + +/** Сводка дней недели приёма пищи: «ежедневно» или короткие названия. */ +function daysLabel(days: number[], t: (k: string) => string): string { + if (days.length === 0) return t('meals.everyday') + const labels = t('meals.weekdays').split(',') + // Порядок Пн..Вс для читабельности. + const order = [1, 2, 3, 4, 5, 6, 0] + return order + .filter((d) => days.includes(d)) + .map((d) => labels[d]) + .join(', ') +} + +export default function Meals(): JSX.Element { + const meals = useAppStore((s) => s.state?.meals ?? []) + const [editorOpen, setEditorOpen] = useState(false) + const [editing, setEditing] = useState(null) + const { t } = useT() + + const enabled = meals.filter((m) => m.enabled) + const disabled = meals.filter((m) => !m.enabled) + + async function addPreset( + preset: (typeof MEAL_PRESETS)[number] + ): Promise { + await window.api.addMeal({ + name: t(preset.nameKey), + time: preset.time, + icon: preset.icon, + enabled: true, + days: [] + }) + } + + return ( +
+
+
+
+
+ {t('meals.kicker')} +
+

+ {t('meals.title')} +

+
+ +
+ + {/* Пресеты быстрого добавления */} + +
+ {MEAL_PRESETS.map((p) => ( + + ))} +
+ + {enabled.length > 0 && ( + <> + + + {enabled.map((m, i) => ( + { + setEditing(m) + setEditorOpen(true) + }} + /> + ))} + + + )} + + {disabled.length > 0 && ( + <> + + + {disabled.map((m, i) => ( + { + setEditing(m) + setEditorOpen(true) + }} + /> + ))} + + + )} + + {meals.length === 0 && ( + +
+
+ +
+
+ {t('meals.empty')} +
+
+
+ )} + + setEditorOpen(false)} + onSave={async (draft: MealDraft) => { + if (editing) await window.api.updateMeal(editing.id, draft) + else await window.api.addMeal(draft) + setEditorOpen(false) + }} + /> +
+
+ ) +} + +function MealRow({ + meal, + last, + meta, + onEdit +}: { + meal: Meal + last: boolean + meta: string + onEdit: () => void +}): JSX.Element { + return ( + +
+ +
+ + window.api.toggleMeal(meal.id, v)} + /> + +
+ ) +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 109e0cb..3d46c48 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -8,6 +8,13 @@ export const IPC = { snooze: 'exercise:snooze', skip: 'exercise:skip', + // Meals (приёмы пищи — напоминания по времени суток) + addMeal: 'meal:add', + updateMeal: 'meal:update', + deleteMeal: 'meal:delete', + toggleMeal: 'meal:toggle', + markMealDone: 'meal:markDone', + updateSettings: 'settings:update', getAccentColor: 'system:accentColor', getOsTheme: 'system:osTheme', @@ -60,6 +67,7 @@ export const IPC = { // events from main → renderer evtTick: 'evt:tick', evtFire: 'evt:fire', + evtFireMeal: 'evt:fireMeal', evtMatchEnd: 'evt:matchEnd', evtStateChanged: 'evt:stateChanged', evtThemeChanged: 'evt:themeChanged', diff --git a/src/shared/meals.test.ts b/src/shared/meals.test.ts new file mode 100644 index 0000000..b74f258 --- /dev/null +++ b/src/shared/meals.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { nextMealOccurrence } from './types' + +/** + * Тесты планирования приёмов пищи по времени суток. Используем фиксированную + * «отправную точку» в локальном времени; helper тоже работает в локальном TZ, + * поэтому тесты детерминированы независимо от таймзоны CI. + * + * 2026-01-15 — четверг (getDay() === 4). + */ +const THU_10_00 = new Date(2026, 0, 15, 10, 0, 0, 0).getTime() +const THU_14_00 = new Date(2026, 0, 15, 14, 0, 0, 0).getTime() +const DAY_MS = 24 * 60 * 60 * 1000 + +describe('nextMealOccurrence', () => { + it('возвращает сегодняшнее время, если оно ещё не наступило', () => { + const r = new Date(nextMealOccurrence('13:00', [], THU_10_00)) + expect(r.getDate()).toBe(15) + expect(r.getHours()).toBe(13) + expect(r.getMinutes()).toBe(0) + }) + + it('переносит на завтра, если время сегодня уже прошло', () => { + const r = new Date(nextMealOccurrence('08:00', [], THU_10_00)) + expect(r.getDate()).toBe(16) + expect(r.getHours()).toBe(8) + }) + + it('всегда строго в будущем относительно from', () => { + expect(nextMealOccurrence('13:00', [], THU_10_00)).toBeGreaterThan( + THU_10_00 + ) + expect(nextMealOccurrence('08:00', [], THU_10_00)).toBeGreaterThan( + THU_10_00 + ) + }) + + it('учитывает фильтр дней недели (только пятница)', () => { + // Четверг 10:00, напоминание 13:00, дни = [5] (пятница) → завтра 16-е. + const r = new Date(nextMealOccurrence('13:00', [5], THU_10_00)) + expect(r.getDate()).toBe(16) + expect(r.getDay()).toBe(5) + expect(r.getHours()).toBe(13) + }) + + it('сегодня входит в фильтр и время не прошло → сегодня', () => { + const r = new Date(nextMealOccurrence('13:00', [4], THU_10_00)) + expect(r.getDate()).toBe(15) + expect(r.getDay()).toBe(4) + }) + + it('единственный день недели, время прошло → следующая неделя', () => { + // Четверг 14:00, 13:00 уже прошло, дни = [4] → следующий четверг 22-е. + const r = new Date(nextMealOccurrence('13:00', [4], THU_14_00)) + expect(r.getDate()).toBe(22) + expect(r.getDay()).toBe(4) + }) + + it('пустой массив дней = каждый день', () => { + const r = new Date(nextMealOccurrence('23:59', [], THU_10_00)) + expect(r.getDate()).toBe(15) + }) + + it('малформированное время → +24ч (safety)', () => { + expect(nextMealOccurrence('99:99', [], THU_10_00)).toBe(THU_10_00 + DAY_MS) + expect(nextMealOccurrence('not-a-time', [], THU_10_00)).toBe( + THU_10_00 + DAY_MS + ) + }) +}) diff --git a/src/shared/types.ts b/src/shared/types.ts index 098a2b3..8b291c9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -42,6 +42,40 @@ export type Exercise = { adaptive?: boolean } +/** + * Приём пищи — напоминание ПО ВРЕМЕНИ СУТОК (в отличие от Exercise, который + * по интервалу). Срабатывает, когда настенные часы достигают `time` в активный + * день недели; после этого `nextFireAt` пересчитывается на следующее вхождение. + */ +export type Meal = { + id: string + name: string + /** "HH:MM" 24ч — время напоминания. */ + time: string + icon: string + enabled: boolean + /** Дни недели 0=Вс..6=Сб, когда напоминать. Пусто = каждый день. */ + days: number[] + /** Вычисляемое: epoch ms следующего срабатывания. */ + nextFireAt: number + lastDoneAt?: number +} + +/** Пресет быстрого добавления приёма пищи. Имя резолвится через i18n. */ +export type MealPreset = { + /** i18n-ключ локализованного имени, напр. 'meals.preset.breakfast'. */ + nameKey: string + time: string + icon: string +} + +export const MEAL_PRESETS: MealPreset[] = [ + { nameKey: 'meals.preset.breakfast', time: '08:00', icon: 'Coffee' }, + { nameKey: 'meals.preset.lunch', time: '13:00', icon: 'UtensilsCrossed' }, + { nameKey: 'meals.preset.dinner', time: '19:00', icon: 'Soup' }, + { nameKey: 'meals.preset.snack', time: '16:00', icon: 'Apple' } +] + export type NotificationMode = 'toast' | 'modal' | 'both' export type Theme = 'light' | 'dark' | 'system' export type Language = 'ru' | 'en' @@ -99,6 +133,7 @@ export type Settings = { */ export type AppState = { exercises: Exercise[] + meals: Meal[] settings: Settings challenges: Challenge[] gamesEnabled: Partial> @@ -314,6 +349,37 @@ export function isQuietAt(qh: QuietHours, now: Date): boolean { return false } +/** + * Следующее срабатывание приёма пищи СТРОГО после `fromMs`: ближайший день + * (включая сегодня, если время ещё не прошло), чей weekday входит в `days` + * (пустой массив = каждый день). Считает через календарную арифметику + * (`setDate`/`setHours`), а не ms — корректно переживает переход на летнее/ + * зимнее время (см. урок history.ts). Малформ `time` → `fromMs + 24ч`. + */ +export function nextMealOccurrence( + time: string, + days: number[], + fromMs: number +): number { + const hm = parseHHMM(time) + const dayMs = 24 * 60 * 60 * 1000 + if (hm === null) return fromMs + dayMs + const h = Math.floor(hm / 60) + const min = hm % 60 + const base = new Date(fromMs) + // 0..7: ищем ближайший активный день. 7 — запас на случай, когда выбран + // единственный день недели, и сегодняшнее время уже прошло. + for (let i = 0; i <= 7; i++) { + const cand = new Date(base) + cand.setDate(cand.getDate() + i) + cand.setHours(h, min, 0, 0) + if (cand.getTime() <= fromMs) continue + const dow = cand.getDay() + if (days.length === 0 || days.includes(dow)) return cand.getTime() + } + return fromMs + dayMs +} + export const SAMPLE_EXERCISES: Omit[] = [ { name: 'Приседания', @@ -357,6 +423,22 @@ export const SAMPLE_EXERCISES: Omit[] = [ } ] +/** + * Стартовые приёмы пищи — выключены по умолчанию (как hydration/eyes/posture). + * Пользователь включает нужные на вкладке «Питание» или добавляет свои. + */ +export const SAMPLE_MEALS: Omit[] = [ + { name: 'Завтрак', time: '08:00', icon: 'Coffee', enabled: false, days: [] }, + { + name: 'Обед', + time: '13:00', + icon: 'UtensilsCrossed', + enabled: false, + days: [] + }, + { name: 'Ужин', time: '19:00', icon: 'Soup', enabled: false, days: [] } +] + export type UpdaterStatus = | { kind: 'idle'; lastCheckedAt?: number } | { kind: 'unsupported'; reason: string }