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:
@@ -15,6 +15,7 @@ import type {
|
||||
Challenge,
|
||||
Exercise,
|
||||
GameStat,
|
||||
Meal,
|
||||
Settings,
|
||||
Theme,
|
||||
Language,
|
||||
@@ -78,6 +79,34 @@ function oneOf<T extends string>(
|
||||
: 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<Meal, 'id' | 'nextFireAt' | 'lastDoneAt'> | 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<Omit<Meal, 'id'>> | null {
|
||||
if (!isObj(raw)) return null
|
||||
const out: Partial<Omit<Meal, 'id'>> = {}
|
||||
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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user