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:
@@ -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<Settings>) : {})
|
||||
@@ -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, 'id' | 'nextFireAt' | 'lastDoneAt'>
|
||||
): 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<Omit<Meal, 'id'>>
|
||||
): 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 (челлендж может ссылаться на упражнение, которое
|
||||
|
||||
Reference in New Issue
Block a user