feat(meals): вкладка «Питание» — напоминания о еде по времени суток
Новая модель Meal — напоминание по настенным часам (time HH:MM + дни недели), в отличие от interval-based Exercise. Отдельная вкладка «Питание» с пресетами быстрого добавления (Завтрак/Обед/Ужин/Перекус). - shared: тип Meal, meals в AppState, nextMealOccurrence (DST-safe), SAMPLE_MEALS, MEAL_PRESETS; IPC-каналы meal:* + evtFireMeal - main: валидация (строгая HH:MM-проверка диапазона), store-мутаторы с пересчётом nextFireAt, scheduler.checkDueMeals (гейт только globalEnabled, grace-окно 120с, игнор тихих часов/ВКС), notifications.fireMealReminder, IPC-хендлеры - renderer: вкладка Meals + MealEditor (время/дни/иконка), MealReminder в окне напоминания (Поел/Отложить, TTS), пункт в Sidebar, маршрут, i18n RU/EN, иконки UtensilsCrossed/Soup - persistence: meals additive (без bump схемы — старые state'ы получают []) - +24 теста (203 -> 227): nextMealOccurrence, валидаторы приёмов пищи, scheduler meal-gating (вкл/выкл, grace, игнор тихих часов) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Record<GameId, boolean>>
|
||||
@@ -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<Exercise, 'id' | 'nextFireAt'>[] = [
|
||||
{
|
||||
name: 'Приседания',
|
||||
@@ -357,6 +423,22 @@ export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Стартовые приёмы пищи — выключены по умолчанию (как hydration/eyes/posture).
|
||||
* Пользователь включает нужные на вкладке «Питание» или добавляет свои.
|
||||
*/
|
||||
export const SAMPLE_MEALS: Omit<Meal, 'id' | 'nextFireAt'>[] = [
|
||||
{ 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 }
|
||||
|
||||
Reference in New Issue
Block a user