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:
@@ -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 {
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/exercises" element={<Exercises />} />
|
||||
<Route path="/meals" element={<Meals />} />
|
||||
<Route path="/games" element={<GamesPage />} />
|
||||
<Route path="/challenges" element={<ChallengesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
|
||||
@@ -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<string> }
|
||||
|
||||
/** Минимальный нативный 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 (
|
||||
<MealReminder
|
||||
key={mode.meal.id + ':' + mode.meal.nextFireAt}
|
||||
meal={mode.meal}
|
||||
snoozeMinutes={settings?.snoozeMinutes ?? 5}
|
||||
lang={lang}
|
||||
onClose={close}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MatchSummaryView
|
||||
summary={mode.summary}
|
||||
@@ -350,6 +375,106 @@ function ExerciseReminder({
|
||||
)
|
||||
}
|
||||
|
||||
function MealReminder({
|
||||
meal,
|
||||
snoozeMinutes,
|
||||
lang,
|
||||
onClose
|
||||
}: {
|
||||
meal: Meal
|
||||
snoozeMinutes: number
|
||||
lang: Language
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const t = (key: string, vars?: Record<string, string | number>): string =>
|
||||
translate(lang, key, vars)
|
||||
|
||||
async function done(): Promise<void> {
|
||||
await window.api.markMealDone(meal.id)
|
||||
onClose()
|
||||
}
|
||||
async function snooze(): Promise<void> {
|
||||
// «Отложить» = напомнить снова через 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 (
|
||||
<div className="reminder-shell flex flex-col h-full">
|
||||
<div className="titlebar-drag h-8 px-2 flex items-center justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-destructive hover:text-white text-text/45 active:scale-90 transition-all"
|
||||
aria-label={t('btn.close')}
|
||||
>
|
||||
<X size={13} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-8 text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0.7, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
|
||||
className="relative mb-6"
|
||||
>
|
||||
<div className="w-24 h-24 rounded-full bg-success text-white grid place-items-center shadow-[0_8px_30px_-8px_rgb(var(--success)/0.5)]">
|
||||
<Icon name={meal.icon} size={44} strokeWidth={2} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="text-[13px] uppercase tracking-[0.18em] text-success font-bold">
|
||||
{t('meal.cta')}
|
||||
</div>
|
||||
<h1 className="font-serif text-[30px] leading-tight tracking-tight mt-2 mb-3 font-bold">
|
||||
{meal.name}
|
||||
</h1>
|
||||
<div className="text-[13px] text-text/65 mt-1 inline-flex items-center gap-1.5 font-medium font-mono-num">
|
||||
<Clock size={12} strokeWidth={2.4} /> {meal.time}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
<button
|
||||
onClick={done}
|
||||
className="w-full h-12 rounded-2xl bg-success text-white text-[16px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Check size={17} strokeWidth={2.5} /> {t('meal.btn.ate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={snooze}
|
||||
className="w-full h-11 rounded-2xl bg-surface-2 text-text text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Clock size={15} strokeWidth={2.5} />{' '}
|
||||
{t('btn.snooze_min', { n: snoozeMinutes })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchSummaryView({
|
||||
summary,
|
||||
done,
|
||||
|
||||
204
src/renderer/src/components/MealEditor.tsx
Normal file
204
src/renderer/src/components/MealEditor.tsx
Normal file
@@ -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<MealDraft>(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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={meal ? t('editor.meal.title.edit') : t('editor.meal.title.new')}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="plain" onClick={onClose}>
|
||||
{t('btn.cancel')}
|
||||
</Button>
|
||||
<Button disabled={!canSave} onClick={() => onSave(draft)}>
|
||||
{t('btn.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-2xl bg-surface-2 p-4 flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-accent text-white grid place-items-center shrink-0">
|
||||
<Icon name={draft.icon} size={26} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-display text-[18px] font-semibold tracking-tight truncate">
|
||||
{draft.name || t('editor.meal.preview.placeholder')}
|
||||
</div>
|
||||
<div className="text-[13px] text-text/55 mt-0.5 font-mono-num">
|
||||
{draft.time}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label={t('editor.field.name')}>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder={t('editor.meal.name.placeholder')}
|
||||
className="ios-input"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t('editor.meal.field.time')}>
|
||||
<input
|
||||
type="time"
|
||||
value={draft.time}
|
||||
onChange={(e) => setDraft({ ...draft, time: e.target.value })}
|
||||
className="ios-input font-mono-num"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t('editor.meal.field.days')}>
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{WEEKDAY_ORDER.map((dow) => {
|
||||
const active = draft.days.includes(dow)
|
||||
return (
|
||||
<button
|
||||
key={dow}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => toggleDay(dow)}
|
||||
className={[
|
||||
'h-10 rounded-xl text-[13px] font-semibold transition-all active:scale-95',
|
||||
active
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-surface-2 text-text/65 hover:text-text'
|
||||
].join(' ')}
|
||||
>
|
||||
{weekdayLabels[dow]}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[12px] text-text/55 mt-1.5 leading-snug">
|
||||
{t('editor.meal.field.days.hint')}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label={t('editor.field.icon')}>
|
||||
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
|
||||
{ICON_CHOICES.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setDraft({ ...draft, icon: name })}
|
||||
className={[
|
||||
'h-10 w-10 grid place-items-center rounded-xl transition-all active:scale-90',
|
||||
draft.icon === name
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-surface text-text/65 hover:text-text'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={name} size={17} strokeWidth={2.2} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.ios-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: 12px;
|
||||
border: 0;
|
||||
background: rgb(var(--surface-2));
|
||||
color: rgb(var(--text));
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: box-shadow .15s ease;
|
||||
}
|
||||
.ios-input:focus {
|
||||
box-shadow: 0 0 0 2px rgb(var(--accent) / 0.45);
|
||||
}
|
||||
`}</style>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-[12px] font-medium text-text/55 mb-1.5">
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string>(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)
|
||||
})
|
||||
|
||||
@@ -21,7 +21,9 @@ export const ICON_CHOICES = [
|
||||
'Apple',
|
||||
'GlassWater',
|
||||
'BookOpen',
|
||||
'Sparkles'
|
||||
'Sparkles',
|
||||
'UtensilsCrossed',
|
||||
'Soup'
|
||||
] as const
|
||||
|
||||
export type IconName = (typeof ICON_CHOICES)[number]
|
||||
|
||||
@@ -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<IconName, React.ComponentType<LucideProps>> = {
|
||||
Apple,
|
||||
GlassWater,
|
||||
BookOpen,
|
||||
Sparkles
|
||||
Sparkles,
|
||||
UtensilsCrossed,
|
||||
Soup
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
201
src/renderer/src/pages/Meals.tsx
Normal file
201
src/renderer/src/pages/Meals.tsx
Normal file
@@ -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<Meal | null>(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<void> {
|
||||
await window.api.addMeal({
|
||||
name: t(preset.nameKey),
|
||||
time: preset.time,
|
||||
icon: preset.icon,
|
||||
enabled: true,
|
||||
days: []
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<div className="text-[14px] text-text/65 font-semibold">
|
||||
{t('meals.kicker')}
|
||||
</div>
|
||||
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
||||
{t('meals.title')}
|
||||
</h1>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
setEditorOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Пресеты быстрого добавления */}
|
||||
<SectionHeader title={t('meals.presets')} />
|
||||
<div className="flex flex-wrap gap-2 mb-7">
|
||||
{MEAL_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.nameKey}
|
||||
onClick={() => addPreset(p)}
|
||||
className="inline-flex items-center gap-2 h-10 px-3.5 rounded-2xl bg-surface-2 hover:bg-accent/15 hover:text-accent text-text/80 text-[14px] font-semibold transition-colors active:scale-95"
|
||||
>
|
||||
<Icon name={p.icon} size={16} strokeWidth={2.3} />
|
||||
{t(p.nameKey)}
|
||||
<span className="font-mono-num text-text/45 text-[13px]">
|
||||
{p.time}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{enabled.length > 0 && (
|
||||
<>
|
||||
<SectionHeader
|
||||
title={t('meals.section.active', { n: enabled.length })}
|
||||
/>
|
||||
<Card className="mb-6">
|
||||
{enabled.map((m, i) => (
|
||||
<MealRow
|
||||
key={m.id}
|
||||
meal={m}
|
||||
last={i === enabled.length - 1}
|
||||
meta={`${m.time} · ${daysLabel(m.days, t)}`}
|
||||
onEdit={() => {
|
||||
setEditing(m)
|
||||
setEditorOpen(true)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{disabled.length > 0 && (
|
||||
<>
|
||||
<SectionHeader
|
||||
title={t('meals.section.disabled', { n: disabled.length })}
|
||||
/>
|
||||
<Card>
|
||||
{disabled.map((m, i) => (
|
||||
<MealRow
|
||||
key={m.id}
|
||||
meal={m}
|
||||
last={i === disabled.length - 1}
|
||||
meta={`${m.time} · ${daysLabel(m.days, t)}`}
|
||||
onEdit={() => {
|
||||
setEditing(m)
|
||||
setEditorOpen(true)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{meals.length === 0 && (
|
||||
<Card>
|
||||
<div className="px-5 py-12 flex flex-col items-center text-center">
|
||||
<div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
|
||||
<UtensilsCrossed size={24} strokeWidth={2.3} />
|
||||
</div>
|
||||
<div className="text-text/65 text-[15px] font-medium max-w-xs leading-snug">
|
||||
{t('meals.empty')}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<MealEditor
|
||||
open={editorOpen}
|
||||
meal={editing}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
onSave={async (draft: MealDraft) => {
|
||||
if (editing) await window.api.updateMeal(editing.id, draft)
|
||||
else await window.api.addMeal(draft)
|
||||
setEditorOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MealRow({
|
||||
meal,
|
||||
last,
|
||||
meta,
|
||||
onEdit
|
||||
}: {
|
||||
meal: Meal
|
||||
last: boolean
|
||||
meta: string
|
||||
onEdit: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Row last={last}>
|
||||
<div
|
||||
className={[
|
||||
'w-9 h-9 rounded-lg grid place-items-center shrink-0',
|
||||
meal.enabled ? 'bg-accent text-white' : 'bg-text/15 text-text/45'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon name={meal.icon} size={18} strokeWidth={2.2} />
|
||||
</div>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="flex-1 min-w-0 text-left active:opacity-70 transition-opacity"
|
||||
>
|
||||
<div className="text-[16px] font-semibold truncate leading-tight">
|
||||
{meal.name}
|
||||
</div>
|
||||
<div className="text-[14px] text-text/65 mt-1 font-medium font-mono-num">
|
||||
{meta}
|
||||
</div>
|
||||
</button>
|
||||
<Switch
|
||||
checked={meal.enabled}
|
||||
onChange={(v) => window.api.toggleMeal(meal.id, v)}
|
||||
/>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="text-text/30 hover:text-text/60 transition-colors"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user