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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Exercise>) => {
|
||||
h.updateExercise(id, patch)
|
||||
const ex = h.exercises.find((e) => e.id === id)
|
||||
return ex ? { ...ex, ...patch } : undefined
|
||||
},
|
||||
updateMeal: (id: string, patch: Partial<Meal>) => {
|
||||
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<typeof import('./scheduler')> {
|
||||
return import('./scheduler')
|
||||
}
|
||||
|
||||
function makeMeal(over: Partial<Meal> = {}): 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (челлендж может ссылаться на упражнение, которое
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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