4 Commits

Author SHA1 Message Date
AnRil
c0827f887f chore(release): v0.6.0 2026-06-05 22:16:52 +07:00
AnRil
bef733a877 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>
2026-06-03 23:45:34 +07:00
AnRil
0cace2975d chore(remote): миграция Gitea-URL на сабдомен git.
Gitea переехал с path-prefix (xn--90adajar8af4h.xn--p1ai/git/) на
выделенный сабдомен (git.xn--90adajar8af4h.xn--p1ai). Старый URL теперь
отдаёт чужое приложение и для git мёртв.

- package.json: publish.url (канал авто-апдейта) -> новый хост
- scripts/release.ps1, upload-release-assets.ps1: $giteaHost (API + release URL)
- README, CHANGELOG, RELEASING.md, CLAUDE.md: ссылки на репозиторий/релизы

Прим.: уже установленные копии (<=0.5.8) запекли старый URL в бинарник —
их авто-апдейт нужно мигрировать отдельно (bridge-теги), правкой конфига
это ретроактивно не лечится.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:03:16 +07:00
AnRil
46b3d59b66 feat(robustness+ui): отказоустойчивость main, тесты, a11y-полировка, лицензия
Надёжность main-процесса:
- глобальные uncaughtException/unhandledRejection (лог + flushNow)
- safeHandle/safeOn вокруг всех IPC-хендлеров (не падаем молча, generic-ошибка наружу)
- таймаут 4s на tasklist, Atomics.wait вместо busy-spin на exit-записи
- единый log.error для фоновых сбоев вместо console.error/тишины

Тесты (178 -> 203): meeting-detect, scheduler-gating, store (миграции/карантин/cap).

UI/UX:
- prefers-reduced-motion через MotionConfig + CSS media-блок
- Spinner/Skeleton примитивы, loading-состояния вместо пустых заглушек
- aria-live анонсы достижений и выполнения (useAnnounce)
- оформленные пустые состояния, клавиатура в меню ExerciseCard

Лицензия: проприетарный LICENSE + правка README/CLAUDE.md, счётчик тестов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 13:23:53 +07:00
44 changed files with 2276 additions and 129 deletions

View File

@@ -446,15 +446,15 @@
иконки), системный трей, автозапуск с Windows, native-уведомления,
NSIS-инсталлятор, auto-update через electron-updater.
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.8...HEAD
[0.5.8]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.8
[0.5.7]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.7
[0.5.6]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.6
[0.5.5]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.5
[0.5.4]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.4
[0.5.3]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.3
[0.5.2]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.2
[0.5.1]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.1
[0.5.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.0
[0.4.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.4.0
[0.2.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.2.0
[Unreleased]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.5.8...HEAD
[0.5.8]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.8
[0.5.7]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.7
[0.5.6]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.6
[0.5.5]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.5
[0.5.4]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.4
[0.5.3]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.3
[0.5.2]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.2
[0.5.1]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.1
[0.5.0]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.0
[0.4.0]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.4.0
[0.2.0]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.2.0

View File

@@ -12,7 +12,7 @@
- **Build**: electron-vite 2 + Vite 5 + electron-builder 25 (NSIS, x64 only)
- **UI**: React 18 + TypeScript 5 + Tailwind 3 + framer-motion + react-router (HashRouter) + zustand 5
- **Auto-update**: electron-updater 6, generic provider, фиксированный канал
- **Тесты**: Vitest 4 (53 теста, все зелёные)
- **Тесты**: Vitest 4 (227 тестов, все зелёные)
- **Lint/format**: ESLint 8 (flat-ish .eslintrc.cjs) + Prettier 3 + EditorConfig
- **Иконки**: lucide-react (whitelisted lookup через `ICON_CHOICES`)
- **Шрифты**: Plus Jakarta Sans, Bricolage Grotesque, JetBrains Mono (Google Fonts CDN)
@@ -38,8 +38,14 @@
- string cap 200 chars, enum-валидация для theme/lang/notify-mode/stat
- HH:MM regex для quietHours, dedup days
- Strip `id` из updateExercise/updateChallenge patch
- **Error-boundary**: все хендлеры обёрнуты в `safeHandle`/`safeOn` (`src/main/ipc.ts`) — исключение логируется в `latest.log`, наружу уходит generic `ipc-failed` (не падаем молча, не утекают детали)
- **Dev-only**: `dev:simulateMatchEnd` gated на `!app.isPackaged`
### Отказоустойчивость main
- **Глобальные хендлеры** в `src/main/index.ts`: `uncaughtException` (лог + `flushNow`) и `unhandledRejection` (лог) — процесс не исчезает молча
- **tasklist timeout** 4s в `meeting-detect.ts` (зависший child не копится)
- **Sync write на exit** через `Atomics.wait` (не busy-spin) в `store.ts`
### Auto-update (КРИТИЧНО)
- **Фиксированный URL канала**: `…/releases/download/update-channel/latest.yml` — никогда не меняется
- **НЕ** `…/releases/download/v${version}/…` (старая схема ломалась: установленная копия видела только свой релиз)
@@ -62,6 +68,20 @@
- Wrap-around (22:00 → 07:00) корректно — при wrap-active проверяется день *начала* окна
- Тесты в `src/shared/quiet-hours.test.ts`
### Питание / приёмы пищи (вкладка «Питание»)
- **Отдельная модель `Meal`** (`src/shared/types.ts`): напоминание ПО ВРЕМЕНИ СУТОК
(`time` HH:MM + `days` weekdays), в отличие от interval-based `Exercise`
- `nextMealOccurrence(time, days, fromMs)` — следующее срабатывание, календарная
арифметика (DST-safe, как history.ts). Тесты в `src/shared/meals.test.ts`
- Scheduler `checkDueMeals()` (`src/main/scheduler.ts`): гейтит **только**
`globalEnabled` (НЕ тихие часы / НЕ ВКС — время задано пользователем явно).
Grace-окно `MEAL_GRACE_MS=120s`: приём, пропущенный давно (сон/выкл), тихо
переносится без срабатывания, чтобы не вывалить пачку напоминаний разом
- Окно напоминания: `evtFireMeal``MealReminder` в `ReminderApp.tsx` (зелёный
акцент `bg-success`, кнопки «Поел» / «Отложить»)
- Пресеты быстрого добавления — `MEAL_PRESETS` (имена через i18n-ключи)
- Время валидируется строго (`validHHMM` в validate.ts — диапазон, не только форма)
### История / стрики
- `src/renderer/src/lib/history.ts` — DST-safe через `shiftDays()` (calendar `setDate`, не ms-арифметика)
- Cap 10k записей, trim oldest 10% на overflow
@@ -100,7 +120,7 @@ npm run release -- -Bump patch
## Gitea remote
- URL: `https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude` (Punycode для `президент.рф`)
- URL: `https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude` (Punycode для `президент.рф`; Gitea переехал с `…/git/` на сабдомен `git.`)
- User: `anril`
- Auth: см. `~/.claude/projects/.../memory/gitea_remote.md`
- **Actions выключены** (`has_actions: false`) — релизим через PowerShell, runners не настроены
@@ -113,27 +133,44 @@ npm run release -- -Bump patch
| `package.json` | version, publish.url, scripts, deps |
| `src/main/store.ts` | persistence, migrations, validation, atomic writes |
| `src/main/ipc.ts` | IPC handlers с валидацией |
| `src/main/scheduler.ts` | таймеры упражнений, powerMonitor |
| `src/main/scheduler.ts` | таймеры упражнений (interval) + приёмы пищи (clock-time), powerMonitor |
| `src/main/games/dota2.ts` + `gsi-server.ts` | GSI приём матчей |
| `src/main/updater.ts` | auto-update logic, silent retries |
| `src/shared/types.ts` | shared типы, дефолты, isQuietAt |
| `src/shared/ipc.ts` | IPC channel types |
| `src/renderer/src/i18n/dict.ts` | словари |
| `src/renderer/src/pages/Dashboard.tsx` | главная |
| `src/renderer/src/ReminderApp.tsx` | окно напоминания |
| `src/renderer/src/pages/Meals.tsx` + `components/MealEditor.tsx` | вкладка «Питание» |
| `src/renderer/src/ReminderApp.tsx` | окно напоминания (упражнение / еда / матч) |
## Тесты (53)
## Тесты (227)
```
src/shared/types.test.ts (4)
src/shared/quiet-hours.test.ts (7)
src/renderer/src/lib/format.test.ts (8)
src/renderer/src/lib/history.test.ts (13)
src/main/validate.test.ts (78)
src/renderer/src/lib/history.test.ts (31)
src/renderer/src/i18n/i18n.test.ts (15)
src/renderer/src/lib/format.test.ts (14)
src/main/scheduler.test.ts (13) ← main: gating + приёмы пищи
src/main/games/vdf.test.ts (11)
src/renderer/src/i18n/i18n.test.ts (10)
src/main/store.test.ts (10) ← main: миграции/карантин/cap
src/renderer/src/lib/achievements.test.ts (10)
src/shared/release-notes.test.ts (9)
src/shared/meals.test.ts (8) ← nextMealOccurrence (DST-safe)
src/main/meeting-detect.test.ts (7) ← main: детект ВКС + кэш/timeout
src/shared/quiet-hours.test.ts (7)
src/main/adaptive.test.ts (6)
src/shared/types.test.ts (4)
src/renderer/src/lib/icon-choices.test.ts (4)
```
Покрываются: helpers, история/стрики (DST), тихие часы (wrap+filter), VDF-парсер Steam, i18n с плюрализацией, дефолты.
Покрываются: IPC-валидация (упражнения/челленджи/приёмы пищи), persistence
(миграции/карантин/cap), scheduler-gating (тихие часы/ВКС/daily-goal), планирование
приёмов пищи по времени суток (DST-safe, grace-окно, игнор тихих часов), детект ВКС
(мок child_process), helpers, история/стрики (DST), тихие часы (wrap+filter),
VDF-парсер Steam, достижения, i18n с плюрализацией, дефолты.
Паттерн для main-тестов: `vi.mock('electron'|'./store'|'node:child_process')` +
`vi.resetModules()` + dynamic import (сброс module-level состояния между тестами).
## Технический долг (не для пользователя)

49
LICENSE Normal file
View File

@@ -0,0 +1,49 @@
Exercise Reminder (Laude)
Proprietary Software License
Copyright (c) 2026 AnRil. All rights reserved.
1. Definitions
"Software" means the Exercise Reminder (Laude) application, including its
source code, binaries, installers, assets, and documentation, in any form.
"Author" means the copyright holder named above.
2. Grant
The Author grants you a personal, non-exclusive, non-transferable, revocable
license to install and use the Software on devices you own or control, for
your own personal, non-commercial purposes.
3. Restrictions
Except as expressly permitted by this license or by mandatory applicable
law, you may NOT, without the Author's prior written permission:
(a) copy, publish, distribute, sublicense, sell, rent, or lease the
Software or any part of it;
(b) modify, adapt, translate, or create derivative works of the Software;
(c) reverse engineer, decompile, or disassemble the Software, or otherwise
attempt to derive its source code, except to the extent this
restriction is prohibited by applicable law;
(d) remove or alter any copyright, trademark, or other proprietary notices.
4. Ownership
The Software is licensed, not sold. The Author retains all right, title, and
interest in and to the Software, including all intellectual property rights.
No rights are granted other than those expressly set out in this license.
5. No Warranty
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
6. Limitation of Liability
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING
FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
7. Termination
This license terminates automatically if you breach any of its terms. Upon
termination you must stop using the Software and delete all copies in your
possession.
For permissions beyond the scope of this license, contact the Author through
the project repository.

View File

@@ -2,13 +2,14 @@
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
[![release](https://img.shields.io/badge/release-v0.5.8-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
[![tests](https://img.shields.io/badge/tests-178%20passing-green)]()
[![release](https://img.shields.io/badge/release-v0.5.8-orange)](https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/latest)
[![tests](https://img.shields.io/badge/tests-203%20passing-green)]()
[![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]()
## Что внутри
- **Гибкие напоминания** — любое количество упражнений, интервал от минуты до часов, разные иконки.
- **Питание** — отдельная вкладка с приёмами пищи по времени суток (завтрак/обед/ужин/перекусы), выбор дней недели, пресеты быстрого добавления. Напоминания по настенным часам, а не по интервалу.
- **История и стрики** — heatmap-календарь активности, ежедневный счётчик, серия дней подряд.
- **Тихие часы** — окно времени когда напоминания подавляются (например `22:00 → 08:00`), с выбором дней недели.
- **Сделал частично** — степпер `/+` в окне напоминания: если ты сделал 5 из 10, в историю запишется честное число.
@@ -34,7 +35,7 @@ Windows SmartScreen может предупредить «не доверено
## Разработка
```bash
git clone https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude.git
git clone https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude.git
cd laude
npm install
npm run dev
@@ -66,21 +67,33 @@ npm run release -- -Bump patch # bump версии + tag + push + upload в G
## Тесты
```
src/shared/types.test.ts (4)
src/shared/quiet-hours.test.ts (5)
src/renderer/src/lib/format.test.ts (8)
src/renderer/src/lib/history.test.ts (13)
src/main/validate.test.ts (78)
src/renderer/src/lib/history.test.ts (31)
src/renderer/src/i18n/i18n.test.ts (15)
src/renderer/src/lib/format.test.ts (14)
src/main/scheduler.test.ts (13)
src/main/games/vdf.test.ts (11)
src/renderer/src/i18n/i18n.test.ts (10)
─────────────────────────────────────────
51 ✓
src/main/store.test.ts (10)
src/renderer/src/lib/achievements.test.ts (10)
src/shared/release-notes.test.ts (9)
src/shared/meals.test.ts (8)
src/main/meeting-detect.test.ts (7)
src/shared/quiet-hours.test.ts (7)
src/main/adaptive.test.ts (6)
src/shared/types.test.ts (4)
src/renderer/src/lib/icon-choices.test.ts (4)
──────────────────────────────────────────
227 ✓
```
Покрытие: чистые helpers (форматирование, история/стрики, тихие часы, парсер VDF для Steam-конфигов), i18n с плюрализацией для RU/EN, дефолты shared-типов.
Покрытие: IPC-валидация (упражнения/челленджи/приёмы пищи), persistence (миграции, карантин битого JSON, history cap), scheduler-гейтинг (тихие часы, ВКС-пауза, daily-goal), планирование приёмов пищи по времени суток (DST-safe), детект ВКС, история/стрики (DST), тихие часы (wrap), парсер VDF для Steam-конфигов, достижения, i18n с плюрализацией RU/EN, дефолты shared-типов.
## Лицензия
Пока не указана. По умолчанию все права защищены. Если хочешь форк/использование — открой issue.
Проприетарная — все права защищены. Личное некоммерческое использование
разрешено; копирование, распространение, модификация и реверс-инжиниринг — без
письменного разрешения автора. Полный текст — в файле [LICENSE](LICENSE). По
вопросам использования за рамками лицензии открой issue в репозитории.
## Stack

View File

@@ -40,7 +40,7 @@ latest.yml # манифест: версия +
В `package.json``build.publish.url`:
```
https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/download/update-channel
https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/download/update-channel
```
Этот URL **никогда не меняется**. Все версии (и сегодняшние, и будущие)

View File

@@ -1,6 +1,6 @@
{
"name": "laude",
"version": "0.5.8",
"version": "0.6.0",
"description": "Exercise reminder — Windows desktop app",
"main": "out/main/index.js",
"author": "AnRil",
@@ -101,7 +101,7 @@
},
"publish": {
"provider": "generic",
"url": "https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/download/update-channel",
"url": "https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/download/update-channel",
"channel": "latest"
}
}

View File

@@ -51,7 +51,7 @@ $ErrorActionPreference = 'Stop'
$repoOwner = 'AnRil'
$repoName = 'laude'
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git'
$giteaHost = 'git.xn--90adajar8af4h.xn--p1ai'
$channelTag = 'update-channel'
# --- Pre-flight ----------------------------------------------------------

View File

@@ -32,7 +32,7 @@ $ErrorActionPreference = 'Stop'
$repoOwner = 'AnRil'
$repoName = 'laude'
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git'
$giteaHost = 'git.xn--90adajar8af4h.xn--p1ai'
$apiBase = "https://$giteaHost/api/v1"
if (-not $env:GITEA_TOKEN) {

View File

@@ -13,9 +13,26 @@ import { broadcastState } from './state-actions'
import { startGamesRegistry, stopGamesRegistry } from './games/registry'
import { initUpdater, stopUpdater } from './updater'
import { IPC } from '@shared/ipc'
import { log } from './logger'
const APP_ID = 'com.anril.exercise-reminder'
// Глобальная сеть безопасности: без этих обработчиков необработанное
// исключение/rejection в main-процессе валит приложение молча — пользователь
// видит, что окно просто исчезло, а в логах пусто. Логируем всё в latest.log.
// uncaughtException дополнительно флашит state, чтобы не потерять данные.
process.on('uncaughtException', (err) => {
log.error('[fatal] uncaughtException', err)
try {
flushNow()
} catch {
// flush сам может бросить (диск/AV) — мы уже в аварийном пути, глушим.
}
})
process.on('unhandledRejection', (reason) => {
log.error('[fatal] unhandledRejection', reason)
})
// Must be set BEFORE app.whenReady() for Windows toasts to show
// the correct app name / icon in Action Center.
app.setAppUserModelId(APP_ID)
@@ -38,7 +55,7 @@ if (!gotLock) {
startScheduler()
startGamesRegistry().catch((err) =>
console.error('games registry failed:', err)
log.error('[index] games registry failed', err)
)
initUpdater()
@@ -88,7 +105,7 @@ if (!gotLock) {
try {
await stopGamesRegistry()
} catch (err) {
console.error('[index] stopGamesRegistry threw:', err)
log.error('[index] stopGamesRegistry threw', err)
}
flushNow()
app.exit(0)

View File

@@ -7,15 +7,18 @@ import {
dialog,
shell
} from 'electron'
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
import { readFileSync, writeFileSync } from 'node:fs'
import { IPC } from '@shared/ipc'
import type { Exercise, GameId, Settings } from '@shared/types'
import {
addChallenge,
addExercise,
addMeal,
clearHistory,
deleteChallenge,
deleteExercise,
deleteMeal,
exportState,
getHistory,
getState,
@@ -23,11 +26,13 @@ import {
importState,
markChallengeDone,
markDone,
markMealDone,
setGameEnabled,
skip,
snooze,
updateChallenge,
updateExercise,
updateMeal,
updateSettings
} from './store'
import { broadcastHistoryChanged, broadcastState } from './state-actions'
@@ -57,12 +62,62 @@ import {
validateExerciseInput,
validateExercisePatch,
validateId,
validateMealInput,
validateMealPatch,
validateSettingsPatch,
validateSnoozeMinutes
} from './validate'
import { log } from './logger'
/**
* Враппер вокруг `ipcMain.handle`: ловит любое исключение в обработчике,
* пишет его в лог и отдаёт renderer'у обобщённую ошибку. Без этого один
* упавший хендлер молча обрывает invoke (renderer висит на await) и в проде
* не остаётся следов. Generic-сообщение наружу — не утекают внутренние детали.
*
* Констрейнт `...args: never[]` делает любую сигнатуру хендлера присваиваемой
* (контравариантность параметров), поэтому типы на call-site сохраняются.
*/
function safeHandle<
F extends (event: IpcMainInvokeEvent, ...args: never[]) => unknown
>(channel: string, fn: F): void {
ipcMain.handle(channel, async (event, ...args) => {
try {
const call = fn as unknown as (
e: IpcMainInvokeEvent,
...a: unknown[]
) => unknown
return await call(event, ...args)
} catch (err) {
log.error(`[ipc] ${channel} threw`, err)
throw new Error('ipc-failed')
}
})
}
/**
* Аналог для `ipcMain.on` (fire-and-forget). Ошибку логируем, но не
* пробрасываем — у sender'а нет канала для ответа.
*/
function safeOn<F extends (event: IpcMainEvent, ...args: never[]) => void>(
channel: string,
fn: F
): void {
ipcMain.on(channel, (event, ...args) => {
try {
const call = fn as unknown as (
e: IpcMainEvent,
...a: unknown[]
) => void
call(event, ...args)
} catch (err) {
log.error(`[ipc] ${channel} (on) threw`, err)
}
})
}
export function registerIpc(): void {
ipcMain.handle(IPC.getState, () => {
safeHandle(IPC.getState, () => {
// Без history (см. getStateForRenderer) и с актуальным значением
// autostart из OS — мутацию делаем по копии, не по cache.
const state = getStateForRenderer()
@@ -73,7 +128,7 @@ export function registerIpc(): void {
return state
})
ipcMain.handle(IPC.addExercise, (_e, input: unknown) => {
safeHandle(IPC.addExercise, (_e, input: unknown) => {
const safe = validateExerciseInput(input)
if (!safe) return null
const ex = addExercise(safe)
@@ -81,7 +136,7 @@ export function registerIpc(): void {
return ex
})
ipcMain.handle(
safeHandle(
IPC.updateExercise,
(_e, idRaw: unknown, patchRaw: unknown) => {
const id = validateId(idRaw)
@@ -93,7 +148,7 @@ export function registerIpc(): void {
}
)
ipcMain.handle(IPC.deleteExercise, (_e, idRaw: unknown) => {
safeHandle(IPC.deleteExercise, (_e, idRaw: unknown) => {
const id = validateId(idRaw)
if (!id) return false
const ok = deleteExercise(id)
@@ -101,7 +156,7 @@ export function registerIpc(): void {
return ok
})
ipcMain.handle(
safeHandle(
IPC.toggleExercise,
(_e, idRaw: unknown, enabledRaw: unknown) => {
const id = validateId(idRaw)
@@ -117,7 +172,7 @@ export function registerIpc(): void {
}
)
ipcMain.handle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => {
safeHandle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => {
const id = validateId(idRaw)
if (!id) return null
const ex = markDone(id, validateActualReps(repsRaw))
@@ -126,7 +181,7 @@ export function registerIpc(): void {
return ex
})
ipcMain.handle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => {
safeHandle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => {
const id = validateId(idRaw)
const minutes = validateSnoozeMinutes(minRaw)
if (!id || minutes === null) return null
@@ -136,7 +191,7 @@ export function registerIpc(): void {
return ex
})
ipcMain.handle(IPC.skip, (_e, idRaw: unknown) => {
safeHandle(IPC.skip, (_e, idRaw: unknown) => {
const id = validateId(idRaw)
if (!id) return null
const ex = skip(id)
@@ -145,7 +200,49 @@ export function registerIpc(): void {
return ex
})
ipcMain.handle(IPC.updateSettings, (_e, patchRaw: unknown) => {
// 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
if (patch.startWithWindows !== undefined) {
@@ -165,19 +262,19 @@ export function registerIpc(): void {
return settings
})
ipcMain.handle(IPC.pauseAll, () => {
safeHandle(IPC.pauseAll, () => {
updateSettings({ globalEnabled: false })
broadcastState()
refreshMenu()
})
ipcMain.handle(IPC.resumeAll, () => {
safeHandle(IPC.resumeAll, () => {
updateSettings({ globalEnabled: true })
broadcastState()
forceCheck()
refreshMenu()
})
ipcMain.handle(IPC.getAccentColor, () => {
safeHandle(IPC.getAccentColor, () => {
try {
return '#' + systemPreferences.getAccentColor()
} catch {
@@ -185,45 +282,45 @@ export function registerIpc(): void {
}
})
ipcMain.handle(IPC.getOsTheme, () =>
safeHandle(IPC.getOsTheme, () =>
nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
)
ipcMain.handle(IPC.getAppVersion, () => app.getVersion())
safeHandle(IPC.getAppVersion, () => app.getVersion())
ipcMain.handle(IPC.getMeetingActive, () => isMeetingActiveSync())
safeHandle(IPC.getMeetingActive, () => isMeetingActiveSync())
ipcMain.handle(IPC.quit, () => app.quit())
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
safeHandle(IPC.quit, () => app.quit())
safeHandle(IPC.reminderClose, () => hideReminderWindow())
ipcMain.on(IPC.minimizeMain, (event) => {
safeOn(IPC.minimizeMain, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
})
ipcMain.on(IPC.toggleMaximizeMain, (event) => {
safeOn(IPC.toggleMaximizeMain, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
if (win.isMaximized()) win.unmaximize()
else win.maximize()
})
ipcMain.handle(IPC.isMaximizedMain, (event) => {
safeHandle(IPC.isMaximizedMain, (event) => {
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false
})
ipcMain.on(IPC.closeMain, () => {
safeOn(IPC.closeMain, () => {
const main = getMainWindow()
if (!main) return
if (getState().settings.minimizeToTray) main.hide()
else main.close()
})
ipcMain.on(IPC.hideMain, () => getMainWindow()?.hide())
safeOn(IPC.hideMain, () => getMainWindow()?.hide())
// Games
ipcMain.handle(IPC.gamesList, async () => listGamesStatus())
safeHandle(IPC.gamesList, async () => listGamesStatus())
ipcMain.handle(IPC.gameInstall, async (_e, id: GameId) => {
safeHandle(IPC.gameInstall, async (_e, id: GameId) => {
const status = await installGame(id)
setGameEnabled(id, true)
await toggleGame(id, true)
@@ -233,7 +330,7 @@ export function registerIpc(): void {
return status
})
ipcMain.handle(IPC.gameUninstall, async (_e, id: GameId) => {
safeHandle(IPC.gameUninstall, async (_e, id: GameId) => {
const status = await uninstallGame(id)
setGameEnabled(id, false)
const all = await listGamesStatus()
@@ -242,7 +339,7 @@ export function registerIpc(): void {
return status
})
ipcMain.handle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
safeHandle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
setGameEnabled(id, enabled)
await toggleGame(id, enabled)
const all = await listGamesStatus()
@@ -250,20 +347,20 @@ export function registerIpc(): void {
broadcastState()
})
ipcMain.handle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
safeHandle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
// Opens Steam's library; user manually adds launch options.
shell.openExternal('steam://nav/games/details/570')
})
// Challenges
ipcMain.handle(IPC.addChallenge, (_e, input: unknown) => {
safeHandle(IPC.addChallenge, (_e, input: unknown) => {
const safe = validateChallengeInput(input)
if (!safe) return null
const c = addChallenge(safe)
broadcastState()
return c
})
ipcMain.handle(
safeHandle(
IPC.updateChallenge,
(_e, idRaw: unknown, patchRaw: unknown) => {
const id = validateId(idRaw)
@@ -274,14 +371,14 @@ export function registerIpc(): void {
return c
}
)
ipcMain.handle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
safeHandle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
const id = validateId(idRaw)
if (!id) return false
const ok = deleteChallenge(id)
broadcastState()
return ok
})
ipcMain.handle(
safeHandle(
IPC.toggleChallenge,
(_e, idRaw: unknown, enabledRaw: unknown) => {
const id = validateId(idRaw)
@@ -292,9 +389,9 @@ export function registerIpc(): void {
}
)
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow())
safeHandle(IPC.closeMatchSummary, () => hideReminderWindow())
ipcMain.handle(
safeHandle(
IPC.markChallengeDone,
(_e, idRaw: unknown, repsRaw: unknown) => {
const id = validateId(idRaw)
@@ -311,7 +408,7 @@ export function registerIpc(): void {
// packaged builds — a compromised renderer (XSS, malicious npm dep) could
// otherwise fabricate arbitrary match-end events at will.
if (!app.isPackaged) {
ipcMain.handle(
safeHandle(
IPC.devSimulateMatchEnd,
(_e, id: GameId, stats: Record<string, number>) => {
simulateMatchEnd(id, stats)
@@ -320,19 +417,19 @@ export function registerIpc(): void {
}
// Auto-updater
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus())
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
safeHandle(IPC.updaterStatus, () => getUpdaterStatus())
safeHandle(IPC.updaterCheck, () => checkForUpdates())
// download/install — fire-and-forget. Прогресс и завершение приходят в
// renderer через evtUpdaterStatus, ждать promise бессмысленно — renderer
// только зря держал бы `busy=true` весь download (минуты на медленной сети).
ipcMain.on(IPC.updaterDownload, () => {
safeOn(IPC.updaterDownload, () => {
void downloadUpdate()
})
ipcMain.on(IPC.updaterInstall, () => quitAndInstall())
safeOn(IPC.updaterInstall, () => quitAndInstall())
// History
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) => {
safeHandle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
safeHandle(IPC.clearHistory, (_e, beforeTs?: number) => {
const removed = clearHistory(beforeTs)
if (removed > 0) broadcastHistoryChanged()
return removed
@@ -340,7 +437,7 @@ export function registerIpc(): void {
// Export / Import. Используем native save/open dialogs Electron'а
// renderer не получает прямого доступа к ФС.
ipcMain.handle(IPC.exportState, async (event) => {
safeHandle(IPC.exportState, async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
const stamp = new Date()
.toISOString()
@@ -369,7 +466,7 @@ export function registerIpc(): void {
}
})
ipcMain.handle(IPC.importState, async (event) => {
safeHandle(IPC.importState, async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
const lang = getState().settings.language ?? 'ru'
const result = await dialog.showOpenDialog(win!, {

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
/**
* Тесты эвристики «человек на ВКС». Мокаем `node:child_process.exec`
* (через него идёт `tasklist`), electron BrowserWindow (broadcast no-op) и
* logger. resetModules + dynamic import в каждом тесте — чтобы сбросить
* module-level кэш (`cachedActive`, `lastCheckAt`).
*/
type ExecCb = (err: Error | null, res?: { stdout: string }) => void
const h = vi.hoisted(() => ({
// Текущая реализация exec для конкретного теста.
execImpl: ((_cmd: string, _opts: unknown, cb: ExecCb) =>
cb(null, { stdout: '' })) as (
cmd: string,
opts: unknown,
cb: ExecCb
) => void,
calls: 0,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
}))
vi.mock('node:child_process', () => ({
exec: (cmd: string, opts: unknown, cb: ExecCb) => {
h.calls += 1
h.execImpl(cmd, opts, cb)
}
}))
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
vi.mock('./logger', () => ({ log: h.log }))
/** CSV-строка tasklist для заданного набора .exe. */
function csv(...procs: string[]): string {
return procs.map((p) => `"${p}","1234","Console","1","85,432 K"`).join('\r\n')
}
async function load(): Promise<typeof import('./meeting-detect')> {
return import('./meeting-detect')
}
beforeEach(() => {
vi.resetModules()
h.calls = 0
h.execImpl = (_cmd, _opts, cb) => cb(null, { stdout: '' })
h.log.info.mockClear()
h.log.warn.mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('isMeetingActive', () => {
it('детектит zoom.exe', async () => {
h.execImpl = (_c, _o, cb) => cb(null, { stdout: csv('zoom.exe') })
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(true)
})
it('детектит новые Teams (ms-teams.exe)', async () => {
h.execImpl = (_c, _o, cb) =>
cb(null, { stdout: csv('explorer.exe', 'ms-teams.exe') })
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(true)
})
it('возвращает false когда ВКС-процессов нет', async () => {
h.execImpl = (_c, _o, cb) =>
cb(null, { stdout: csv('explorer.exe', 'code.exe', 'chrome.exe') })
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(false)
})
it('кэширует результат в пределах CACHE_MS (exec вызывается один раз)', async () => {
h.execImpl = (_c, _o, cb) => cb(null, { stdout: csv('discord.exe') })
const { isMeetingActive } = await load()
await isMeetingActive()
await isMeetingActive()
expect(h.calls).toBe(1)
})
it('при падении tasklist возвращает false и логирует warn', async () => {
h.execImpl = (_c, _o, cb) => cb(new Error('ETIMEDOUT'))
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(false)
expect(h.log.warn).toHaveBeenCalled()
})
it('isMeetingActiveSync отражает последний известный результат', async () => {
h.execImpl = (_c, _o, cb) => cb(null, { stdout: csv('webex.exe') })
const mod = await load()
expect(mod.isMeetingActiveSync()).toBe(false) // до первого запроса
await mod.isMeetingActive()
expect(mod.isMeetingActiveSync()).toBe(true)
})
it('на не-Windows возвращает false без вызова tasklist', async () => {
const original = process.platform
Object.defineProperty(process, 'platform', { value: 'linux' })
try {
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(false)
expect(h.calls).toBe(0)
} finally {
Object.defineProperty(process, 'platform', { value: original })
}
})
})

View File

@@ -64,7 +64,12 @@ export async function isMeetingActive(): Promise<boolean> {
// CSV без заголовков (/NH), скрытое окно.
const { stdout } = await execAsync('tasklist /FO CSV /NH', {
windowsHide: true,
maxBuffer: 4 * 1024 * 1024 // tasklist бывает большой
maxBuffer: 4 * 1024 * 1024, // tasklist бывает большой
// Если tasklist подвис (повреждённый WMI, загруженная система) — exec
// сам прибьёт процесс и уйдёт в catch. Без таймаута зависшие child
// накапливались бы при каждом refresh.
timeout: 4000,
killSignal: 'SIGKILL'
})
const lower = stdout.toLowerCase()
for (const proc of MEETING_PROCESSES) {

View File

@@ -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)

242
src/main/scheduler.test.ts Normal file
View File

@@ -0,0 +1,242 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type {
Exercise,
HistoryEntry,
Meal,
QuietHours,
Settings
} from '@shared/types'
import { DEFAULT_SETTINGS } from '@shared/types'
/**
* Тесты gating-логики scheduler'а. Дёргаем публичный `forceCheck()` (он
* сбрасывает lastCheckAt и прогоняет tick → checkDueExercises) и проверяем,
* вызвался ли `fireReminder`. Стор/нотификации/meeting/adaptive замоканы.
*/
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)
}))
vi.mock('electron', () => ({
powerMonitor: { on: vi.fn() },
BrowserWindow: { getAllWindows: () => [] }
}))
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,
fireMealReminder: h.fireMealReminder
}))
vi.mock('./state-actions', () => ({ broadcastState: h.broadcastState }))
vi.mock('./meeting-detect', () => ({
isMeetingActiveSync: () => h.meetingActive,
refreshMeetingState: h.refreshMeetingState
}))
vi.mock('./adaptive', () => ({ adjustNextFireAt: h.adjustNextFireAt }))
function makeExercise(over: Partial<Exercise> = {}): Exercise {
return {
id: 'ex1',
name: 'Приседания',
reps: 10,
icon: 'Activity',
intervalMinutes: 30,
enabled: true,
nextFireAt: Date.now() - 1000, // due by default
...over
}
}
/** Тихие часы, гарантированно покрывающие текущий момент (без wrap). */
function quietWindowAroundNow(): QuietHours {
const now = new Date()
const cur = now.getHours() * 60 + now.getMinutes()
const fromMin = Math.max(0, cur - 60)
const toMin = Math.min(1439, cur + 60)
const fmt = (m: number): string =>
`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(
2,
'0'
)}`
return { enabled: true, from: fmt(fromMin), to: fmt(toMin), days: [] }
}
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()
})
describe('checkDueExercises gating', () => {
it('не fire-ит когда globalEnabled=false', async () => {
h.settings = { ...DEFAULT_SETTINGS, globalEnabled: false }
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('не fire-ит внутри тихих часов', async () => {
h.settings = { ...DEFAULT_SETTINGS, quietHours: quietWindowAroundNow() }
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('не fire-ит когда активна ВКС (meetingAutoPause)', async () => {
h.settings = { ...DEFAULT_SETTINGS, meetingAutoPause: true }
h.meetingActive = true
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.refreshMeetingState).toHaveBeenCalled()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('fire-ит готовое к срабатыванию упражнение и шлёт broadcastState', async () => {
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).toHaveBeenCalledTimes(1)
expect(h.broadcastState).toHaveBeenCalled()
})
it('пропускает выключенные упражнения', async () => {
h.exercises = [makeExercise({ enabled: false })]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('не fire-ит упражнение, чьё время ещё не пришло', async () => {
h.exercises = [makeExercise({ nextFireAt: Date.now() + 60_000 })]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('soft-cap: при закрытой dailyGoal переносит fire, но не показывает', async () => {
h.exercises = [makeExercise({ dailyGoal: 20 })]
h.history = [
{
ts: Date.now(),
exerciseId: 'ex1',
action: 'done',
actualReps: 25
}
]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
// nextFireAt перенесён (на завтра) — updateExercise вызван.
expect(h.updateExercise).toHaveBeenCalled()
})
it('adaptive: применяет adjustNextFireAt к кандидату', async () => {
h.exercises = [makeExercise({ adaptive: true })]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.adjustNextFireAt).toHaveBeenCalled()
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)
})
})

View File

@@ -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()
}
}

198
src/main/store.test.ts Normal file
View File

@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
mkdtempSync,
rmSync,
writeFileSync,
existsSync,
readdirSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
/**
* Тесты persistence-слоя. Мокаем electron.app.getPath на временную директорию
* (новую на каждый тест) и logger. resetModules + dynamic import сбрасывают
* module-level `cache`/`storePath`, чтобы тесты не текли друг в друга.
*/
const h = vi.hoisted(() => ({
userData: '',
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
}))
vi.mock('electron', () => ({
app: {
getPath: () => h.userData,
getVersion: () => '0.0.0-test'
}
}))
vi.mock('./logger', () => ({ log: h.log }))
async function load(): Promise<typeof import('./store')> {
return import('./store')
}
function statePath(): string {
return join(h.userData, 'app-state.json')
}
function corruptFiles(): string[] {
return readdirSync(h.userData).filter((f) => f.includes('.corrupt-'))
}
beforeEach(() => {
vi.resetModules()
h.userData = mkdtempSync(join(tmpdir(), 'laude-store-'))
h.log.error.mockClear()
h.log.warn.mockClear()
})
afterEach(() => {
try {
rmSync(h.userData, { recursive: true, force: true })
} catch {
/* ignore */
}
})
describe('store · cold start', () => {
it('создаёт app-state.json с примерами упражнений на первом запуске', async () => {
const { getState } = await load()
const state = getState()
expect(state.exercises.length).toBeGreaterThan(0)
expect(existsSync(statePath())).toBe(true)
})
})
describe('store · corrupt file quarantine', () => {
it('битый JSON уносится в .corrupt-* и стартует чистый state', async () => {
writeFileSync(statePath(), '{ this is : not json', 'utf-8')
const { getState } = await load()
const state = getState()
expect(state.exercises.length).toBeGreaterThan(0) // initial
expect(corruptFiles().length).toBe(1)
expect(h.log.error).toHaveBeenCalled()
})
it('валидный JSON, но не объект (массив) — тоже карантин', async () => {
writeFileSync(statePath(), '[1,2,3]', 'utf-8')
const { getState } = await load()
expect(getState().exercises.length).toBeGreaterThan(0)
expect(corruptFiles().length).toBe(1)
})
})
describe('store · coerce / migrations', () => {
it('подставляет дефолтные settings когда их нет в файле', async () => {
writeFileSync(
statePath(),
JSON.stringify({ exercises: [], challenges: [], history: [] }),
'utf-8'
)
const { getSettings } = await load()
const s = getSettings()
expect(s.globalEnabled).toBeDefined()
expect(s.notificationMode).toBeDefined()
expect(s.snoozeMinutes).toBeGreaterThan(0)
})
it('файл без __schemaVersion грузится без потери данных', async () => {
const ex = {
id: 'x1',
name: 'Тест',
reps: 10,
icon: 'Dumbbell',
intervalMinutes: 30,
enabled: true,
nextFireAt: Date.now() + 1000
}
writeFileSync(
statePath(),
JSON.stringify({ exercises: [ex], challenges: [], history: [] }),
'utf-8'
)
const { getExercises } = await load()
const list = getExercises()
expect(list).toHaveLength(1)
expect(list[0].name).toBe('Тест')
})
})
describe('store · history cap', () => {
it('обрезает историю когда превышен HISTORY_MAX', async () => {
const ex = {
id: 'x1',
name: 'Приседания',
reps: 10,
icon: 'Activity',
intervalMinutes: 30,
enabled: true,
nextFireAt: Date.now()
}
const big = Array.from({ length: 10_005 }, (_unused, i) => ({
ts: i,
exerciseId: 'x1',
action: 'done' as const,
reps: 10
}))
writeFileSync(
statePath(),
JSON.stringify({ exercises: [ex], challenges: [], history: big }),
'utf-8'
)
const { markDone, getHistory } = await load()
markDone('x1')
// 10005 + 1 = 10006 > 10000 → slice(-9000)
expect(getHistory().length).toBe(9000)
})
})
describe('store · clearHistory', () => {
it('удаляет записи старше границы и возвращает количество', async () => {
const ex = {
id: 'x1',
name: 'A',
reps: 5,
icon: 'Activity',
intervalMinutes: 10,
enabled: true,
nextFireAt: Date.now()
}
const history = [
{ ts: 100, exerciseId: 'x1', action: 'done' as const },
{ ts: 200, exerciseId: 'x1', action: 'done' as const },
{ ts: 300, exerciseId: 'x1', action: 'done' as const }
]
writeFileSync(
statePath(),
JSON.stringify({ exercises: [ex], challenges: [], history }),
'utf-8'
)
const { clearHistory, getHistory } = await load()
const removed = clearHistory(250)
expect(removed).toBe(2)
expect(getHistory().map((e) => e.ts)).toEqual([300])
})
it('отказывается чистить без явной границы (защита от полного wipe)', async () => {
const { clearHistory } = await load()
expect(clearHistory()).toBe(0)
})
})
describe('store · export / import', () => {
it('export даёт валидный JSON со схемой; import парсит его обратно', async () => {
const { exportState, importState } = await load()
const json = exportState()
const parsed = JSON.parse(json)
expect(typeof parsed.__schemaVersion).toBe('number')
expect(parsed.__schemaVersion).toBeGreaterThanOrEqual(1)
expect(importState(json)).toBe(true)
})
it('import отклоняет мусор', async () => {
const { importState } = await load()
expect(importState('not json at all')).toBe(false)
expect(importState('42')).toBe(false)
})
})

View File

@@ -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>) : {})
@@ -303,11 +314,11 @@ function atomicWriteSync(path: string, contents: string): void {
}
const delay = WRITE_RETRY_DELAYS[i]
if (delay === undefined) break
// Event-loop остановлен, async sleep не вернётся — приходится spin.
const until = Date.now() + delay
while (Date.now() < until) {
/* spin */
}
// Event-loop остановлен (exit-path), async sleep не вернётся — нужен
// блокирующий sync sleep. Atomics.wait на «свежем» буфере всегда уходит
// в таймаут (значение совпадает с ожидаемым 0), т.е. честно спит delay мс
// без сжигания CPU — в отличие от старого busy-loop.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
}
}
log.error('[store] atomic sync write failed after retries', lastErr)
@@ -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 (челлендж может ссылаться на упражнение, которое

View File

@@ -127,7 +127,15 @@ async function bootCheckWithRetry(): Promise<void> {
return // success
}
const delay = BOOT_RETRY_DELAYS[attempt]
if (delay === undefined) return // exhausted retries
if (delay === undefined) {
// Исчерпали ретраи — раньше сдавались молча. Логируем, чтобы при
// диагностике было видно «boot-check так и не достучался». Следующая
// попытка — на ближайшем hourly-тике.
log.warn(
'[updater] boot check exhausted retries — will retry on hourly tick'
)
return
}
await new Promise((r) => setTimeout(r, delay))
}
}

View File

@@ -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,

View File

@@ -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
// -----------------------------------------------------------------------

View File

@@ -8,6 +8,7 @@ import type {
GameStatus,
HistoryEntry,
MatchSummary,
Meal,
Settings,
Tick,
UpdaterStatus
@@ -41,6 +42,19 @@ const api = {
ipcRenderer.invoke(IPC.snooze, id, minutes),
skip: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.skip, id),
// Meals
addMeal: (
input: Omit<Meal, 'id' | 'nextFireAt' | 'lastDoneAt'>
): Promise<Meal> => ipcRenderer.invoke(IPC.addMeal, input),
updateMeal: (id: string, patch: Partial<Meal>): Promise<Meal> =>
ipcRenderer.invoke(IPC.updateMeal, id, patch),
deleteMeal: (id: string): Promise<boolean> =>
ipcRenderer.invoke(IPC.deleteMeal, id),
toggleMeal: (id: string, enabled: boolean): Promise<Meal> =>
ipcRenderer.invoke(IPC.toggleMeal, id, enabled),
markMealDone: (id: string): Promise<Meal> =>
ipcRenderer.invoke(IPC.markMealDone, id),
updateSettings: (patch: Partial<Settings>): Promise<Settings> =>
ipcRenderer.invoke(IPC.updateSettings, patch),
@@ -136,6 +150,7 @@ const api = {
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
onFireMeal: (h: Handler<Meal>): Unsub => on(IPC.evtFireMeal, h),
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),
onStateChanged: (h: Handler<AppState>): Unsub => on(IPC.evtStateChanged, h),
onThemeChanged: (h: Handler<'light' | 'dark'>): Unsub =>

View File

@@ -5,9 +5,11 @@ import { Sidebar } from './components/Sidebar'
import { Titlebar } from './components/Titlebar'
import { ErrorBoundary } from './components/ErrorBoundary'
import { WhatsNewModal } from './components/WhatsNewModal'
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'
@@ -100,8 +102,23 @@ export default function App(): JSX.Element {
<RoutedPages onNav={() => setMobileNavOpen(false)} />
</ErrorBoundary>
) : (
// Neutral placeholder — settings (and lang) aren't loaded yet.
<div className="p-8 text-text/45" />
// Skeleton на время гидрации — settings (и язык) ещё не
// загружены, текст показывать рано, но пустота выглядит как
// зависание. Каркас задаёт ожидание «сейчас появится контент».
<div
className="p-6 sm:p-8 space-y-5 max-w-3xl"
role="status"
aria-label="Loading"
>
<Skeleton className="h-9 w-48" />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-40" />
<Skeleton className="h-28" />
</div>
)}
</main>
</div>
@@ -136,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 />} />

View File

@@ -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,

View File

@@ -7,6 +7,7 @@ import {
type AchievementProgress
} from '../lib/achievements'
import { useT } from '../i18n'
import { useAnnounce } from '../lib/useAnnounce'
const CELEBRATED_KEY = 'laude:celebratedAchievements'
@@ -48,6 +49,7 @@ type Props = {
*/
export function AchievementsCard({ history, exercises }: Props): JSX.Element {
const { t } = useT()
const announce = useAnnounce()
const achievements = useMemo(
() => computeAchievements(history, exercises),
@@ -73,11 +75,19 @@ export function AchievementsCard({ history, exercises }: Props): JSX.Element {
if (fresh.size > 0) {
setFreshlyUnlocked(fresh)
saveCelebrated(celebrated)
// Озвучиваем разблокировку для screen-reader'ов — pulse-анимацию они
// не видят.
for (const a of achievements) {
if (fresh.has(a.def.id)) {
announce(t('achievements.announce', { title: t(a.def.titleKey) }))
}
}
// Снимаем «свежесть» через 5 сек чтобы pulse не крутился вечно.
const t = setTimeout(() => setFreshlyUnlocked(new Set()), 5_000)
return () => clearTimeout(t)
const timer = setTimeout(() => setFreshlyUnlocked(new Set()), 5_000)
return () => clearTimeout(timer)
}
return undefined
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [achievements])
const unlocked = achievements.filter((a) => a.unlocked)

View File

@@ -1,11 +1,12 @@
import { motion } from 'framer-motion'
import { Check, MoreHorizontal, Brain, CheckCircle2 } from 'lucide-react'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon'
import { formatCountdown } from '../lib/format'
import { Switch } from './ui/Switch'
import { useT } from '../i18n'
import { useAnnounce } from '../lib/useAnnounce'
type Props = {
exercise: Exercise
@@ -43,21 +44,64 @@ export function ExerciseCard({
// Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем.
const isDue = ms <= 0 && exercise.enabled && !goalReached
const [menuOpen, setMenuOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// При открытии меню переводим фокус на первый пункт — клавиатурный
// пользователь сразу внутри, без слепого Tab'а.
useEffect(() => {
if (!menuOpen) return
menuRef.current
?.querySelector<HTMLButtonElement>('[role="menuitem"]')
?.focus()
}, [menuOpen])
// Esc закрывает и возвращает фокус на триггер; стрелки/Home/End — навигация.
const onMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
const items = Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]'
) ?? []
)
if (items.length === 0) return
const idx = items.indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'Escape') {
e.preventDefault()
setMenuOpen(false)
triggerRef.current?.focus()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
items[(idx + 1) % items.length]?.focus()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
items[(idx - 1 + items.length) % items.length]?.focus()
} else if (e.key === 'Home') {
e.preventDefault()
items[0]?.focus()
} else if (e.key === 'End') {
e.preventDefault()
items[items.length - 1]?.focus()
}
}
// Дедуп rapid double-click на «Готово». Между кликом и обновлением
// nextFireAt (через broadcastState) есть окно ~1 сек, в которое можно
// вызвать markDone повторно и записать лишний entry в историю.
const markDoneInFlightRef = useRef(false)
const { t, lang } = useT()
const announce = useAnnounce()
const handleMarkDone = (): void => {
if (markDoneInFlightRef.current) return
markDoneInFlightRef.current = true
onMarkDone()
// Озвучиваем для screen-reader'ов — кнопка после засчёта исчезает,
// визуальный feedback незрячему недоступен.
announce(`${t('btn.done')}: ${exercise.name}`)
// К моменту окончания таймаута isDue уже false (после store-tick), кнопка
// не рендерится — флаг чистим на всякий случай для будущих кейсов.
setTimeout(() => {
markDoneInFlightRef.current = false
}, 1000)
}
const { t, lang } = useT()
// Ring math
const R = 22
@@ -124,9 +168,12 @@ export function ExerciseCard({
</h3>
<div className="relative">
<button
ref={triggerRef}
onClick={() => setMenuOpen((v) => !v)}
className="w-7 h-7 grid place-items-center rounded-full text-text/45 hover:bg-surface-2 active:scale-90 transition-all"
aria-label={t('titlebar.menu_aria')}
aria-haspopup="menu"
aria-expanded={menuOpen}
>
<MoreHorizontal size={16} />
</button>
@@ -136,8 +183,15 @@ export function ExerciseCard({
className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 top-8 z-20 min-w-[140px] bg-surface rounded-xl shadow-sheet ring-0.5 ring-hairline/30 py-1 overflow-hidden">
<div
ref={menuRef}
role="menu"
aria-label={exercise.name}
onKeyDown={onMenuKeyDown}
className="absolute right-0 top-8 z-20 min-w-[140px] bg-surface rounded-xl shadow-sheet ring-0.5 ring-hairline/30 py-1 overflow-hidden"
>
<button
role="menuitem"
onClick={() => {
setMenuOpen(false)
onEdit()
@@ -147,6 +201,7 @@ export function ExerciseCard({
{t('btn.edit')}
</button>
<button
role="menuitem"
onClick={() => {
setMenuOpen(false)
onDelete()

View 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>
)
}

View File

@@ -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',

View File

@@ -0,0 +1,21 @@
type Props = {
className?: string
}
/**
* Placeholder-блок на время загрузки данных. `aria-hidden` — это чисто
* визуальный шум, screen-reader'у он не нужен (рядом обычно есть role="status"
* или контент появится сам). Пульсация через Tailwind `animate-pulse`
* (гасится при prefers-reduced-motion — см. globals.css).
*/
export function Skeleton({ className = '' }: Props): JSX.Element {
return (
<div
aria-hidden="true"
className={[
'animate-pulse rounded-2xl bg-surface-2/70 dark:bg-surface-2',
className
].join(' ')}
/>
)
}

View File

@@ -0,0 +1,29 @@
import { Loader2 } from 'lucide-react'
type Props = {
size?: number
className?: string
/** Подпись для screen-reader'ов. По умолчанию нейтральное «Loading». */
label?: string
}
/**
* Индикатор асинхронной операции. `role="status"` + aria-label делают его
* слышимым для screen-reader'ов. Вращение через Tailwind `animate-spin`
* (замедляется, но не выключается при prefers-reduced-motion — см. globals.css).
*/
export function Spinner({
size = 16,
className = '',
label = 'Loading'
}: Props): JSX.Element {
return (
<Loader2
size={size}
strokeWidth={2.4}
role="status"
aria-label={label}
className={['animate-spin', className].join(' ')}
/>
)
}

View File

@@ -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': 'Редактировать',
@@ -250,6 +278,7 @@ export const ru: Dict = {
'achievements.title': 'Достижения',
'achievements.unlocked_of': '{n} из {total}',
'achievements.progress': 'осталось {n}',
'achievements.announce': 'Достижение получено: {title}',
'achievement.reps.desc': 'Сделай {target} повторений всего',
'achievement.reps_100.title': 'Сотня',
'achievement.reps_500.title': 'Пятьсот',
@@ -346,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',
@@ -426,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',
@@ -581,6 +638,7 @@ export const en: Dict = {
'achievements.title': 'Achievements',
'achievements.unlocked_of': '{n} of {total}',
'achievements.progress': '{n} to go',
'achievements.announce': 'Achievement unlocked: {title}',
'achievement.reps.desc': '{target} reps total',
'achievement.reps_100.title': 'Century',
'achievement.reps_500.title': 'Five hundred',

View File

@@ -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)
})

View File

@@ -21,7 +21,9 @@ export const ICON_CHOICES = [
'Apple',
'GlassWater',
'BookOpen',
'Sparkles'
'Sparkles',
'UtensilsCrossed',
'Soup'
] as const
export type IconName = (typeof ICON_CHOICES)[number]

View File

@@ -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
}
/**

View File

@@ -0,0 +1,49 @@
import { useCallback } from 'react'
/**
* Озвучивание событий для screen-reader'ов через единый скрытый
* aria-live регион. Анимации/тосты видят зрячие; незрячим нужно явное
* сообщение — иначе «достижение разблокировано» или «упражнение засчитано»
* проходят молча.
*
* Регион — module-level singleton (один на окно), создаётся лениво и живёт
* до закрытия окна. Так не нужен провайдер в дереве компонентов.
*/
let region: HTMLElement | null = null
function ensureRegion(): HTMLElement {
if (region && document.body.contains(region)) return region
const el = document.createElement('div')
el.setAttribute('aria-live', 'polite')
el.setAttribute('aria-atomic', 'true')
el.setAttribute('role', 'status')
// Визуально скрыто, но доступно для screen-reader'ов (sr-only pattern).
Object.assign(el.style, {
position: 'absolute',
width: '1px',
height: '1px',
margin: '-1px',
padding: '0',
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
whiteSpace: 'nowrap',
border: '0'
})
document.body.appendChild(el)
region = el
return el
}
export function useAnnounce(): (message: string) => void {
return useCallback((message: string) => {
if (!message) return
const el = ensureRegion()
// Сброс перед записью: screen-reader игнорирует повторную установку
// идентичного текста. Разносим очистку и значение по разным кадрам,
// чтобы изменение точно зарегистрировалось.
el.textContent = ''
requestAnimationFrame(() => {
el.textContent = message
})
}, [])
}

View File

@@ -1,5 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { MotionConfig } from 'framer-motion'
import './styles/globals.css'
import App from './App'
import ReminderApp from './ReminderApp'
@@ -8,10 +9,15 @@ import { ThemeProvider } from './providers/ThemeProvider'
const params = new URLSearchParams(window.location.search)
const which = params.get('window') ?? 'main'
// reducedMotion="user" — framer-motion сам читает системную настройку
// «уменьшить движение» и глушит transform/layout-анимации (оставляя opacity).
// Один источник истины для обоих окон и всех motion-компонентов.
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MotionConfig reducedMotion="user">
<ThemeProvider>
{which === 'reminder' ? <ReminderApp /> : <App />}
</ThemeProvider>
</MotionConfig>
</React.StrictMode>
)

View File

@@ -139,9 +139,14 @@ export default function ChallengesPage(): JSX.Element {
</>
) : (
<Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
<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">
<Gamepad2 size={24} strokeWidth={2.4} />
</div>
<div className="text-text/65 text-[15px] font-medium max-w-xs leading-snug">
{t('challenges.empty')}
</div>
</div>
</Card>
)}

View File

@@ -93,9 +93,14 @@ export default function Exercises(): JSX.Element {
{exercises.length === 0 && (
<Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
<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">
<Plus size={24} strokeWidth={2.5} />
</div>
<div className="text-text/65 text-[15px] font-medium max-w-xs leading-snug">
{t('exercises.empty')}
</div>
</div>
</Card>
)}

View File

@@ -11,6 +11,7 @@ import {
import { motion } from 'framer-motion'
import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
import { Spinner } from '../components/ui/Spinner'
import { Card, SectionHeader } from '../components/ui/Card'
import type { GameId, GameStatus } from '@shared/types'
import { useT } from '../i18n'
@@ -104,7 +105,11 @@ export default function GamesPage(): JSX.Element {
))}
{games.length === 0 && (
<Card>
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
<div
className="px-5 py-12 flex flex-col items-center gap-3 text-center text-text/55 text-[14px]"
role="status"
>
<Spinner size={22} className="text-accent" label={t('games.scanning')} />
{t('games.scanning')}
</div>
</Card>
@@ -201,7 +206,12 @@ function GameCard({
<div className="flex items-center flex-wrap gap-2 mt-4">
{game.installed && !game.integrationActive && (
<Button onClick={onInstall} disabled={busy} size="sm">
<Download size={14} strokeWidth={2.5} /> {t('btn.connect')}
{busy ? (
<Spinner size={14} />
) : (
<Download size={14} strokeWidth={2.5} />
)}{' '}
{t('btn.connect')}
</Button>
)}
{game.integrationActive && (
@@ -211,7 +221,8 @@ function GameCard({
disabled={busy}
size="sm"
>
<Trash2 size={14} strokeWidth={2.5} /> {t('btn.disconnect')}
{busy ? <Spinner size={14} /> : <Trash2 size={14} strokeWidth={2.5} />}{' '}
{t('btn.disconnect')}
</Button>
)}
{!game.installed && (

View 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>
)
}

View File

@@ -5,6 +5,8 @@ import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard'
import { WhatsNewModal } from '../components/WhatsNewModal'
import { ConfirmModal } from '../components/ui/ConfirmModal'
import { Skeleton } from '../components/ui/Skeleton'
import { Spinner } from '../components/ui/Spinner'
import { RELEASE_NOTES } from '@shared/release-notes'
import { useT } from '../i18n'
import type {
@@ -19,7 +21,18 @@ export default function SettingsPage(): JSX.Element {
const settings = useAppStore((s) => s.state?.settings)
const { t } = useT()
if (!settings)
return <div className="p-8 text-text/45">{t('settings.loading')}</div>
return (
<div
className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12 space-y-5"
role="status"
aria-label={t('settings.loading')}
>
<Skeleton className="h-10 w-56" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-24" />
</div>
)
const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p)
@@ -244,7 +257,9 @@ function AboutCard(): JSX.Element {
function DataCard(): JSX.Element {
const { t } = useT()
const [busy, setBusy] = useState(false)
// Какая операция сейчас идёт — чтобы крутить спиннер на нужной кнопке,
// а не на обеих сразу.
const [busy, setBusy] = useState<'export' | 'import' | null>(null)
const [toast, setToast] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
@@ -256,7 +271,7 @@ function DataCard(): JSX.Element {
}, [toast])
async function onExport(): Promise<void> {
setBusy(true)
setBusy('export')
try {
const r = await window.api.exportState()
if (r.ok && r.path) {
@@ -266,13 +281,13 @@ function DataCard(): JSX.Element {
setToast(t('settings.data.export.err'))
}
} finally {
setBusy(false)
setBusy(null)
}
}
async function performImport(): Promise<void> {
setConfirmOpen(false)
setBusy(true)
setBusy('import')
try {
const r = await window.api.importState()
if (r.ok) setToast(t('settings.data.import.ok'))
@@ -281,7 +296,7 @@ function DataCard(): JSX.Element {
setToast(t('settings.data.import.err'))
}
} finally {
setBusy(false)
setBusy(null)
}
}
@@ -298,9 +313,10 @@ function DataCard(): JSX.Element {
</div>
<button
onClick={onExport}
disabled={busy}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
disabled={busy !== null}
className="inline-flex items-center gap-2 h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
>
{busy === 'export' && <Spinner size={14} />}
{t('settings.data.export.btn')}
</button>
</Row>
@@ -315,9 +331,10 @@ function DataCard(): JSX.Element {
</div>
<button
onClick={() => setConfirmOpen(true)}
disabled={busy}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
disabled={busy !== null}
className="inline-flex items-center gap-2 h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
>
{busy === 'import' && <Spinner size={14} />}
{t('settings.data.import.btn')}
</button>
</Row>

View File

@@ -230,3 +230,25 @@ body {
.dark .text-tertiary {
color: rgb(var(--text-tertiary) / 0.3);
}
/* ===== Reduced motion =====
framer-motion закрывает свои анимации через MotionConfig reducedMotion="user"
(см. main.tsx). Этот блок гасит CSS-анимации/переходы, которые framer не
контролирует (Tailwind animate-spin/-pulse, нативные transition). Спиннеры
намеренно НЕ обнуляем полностью — индикатор загрузки должен крутиться, иначе
пропадает смысл; но замедляем, чтобы не мелькал. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
/* Спиннеры — единственное исключение: оставляем вращение, но медленнее. */
.animate-spin {
animation-duration: 1.2s !important;
animation-iteration-count: infinite !important;
}
}

View File

@@ -8,6 +8,13 @@ export const IPC = {
snooze: 'exercise:snooze',
skip: 'exercise:skip',
// Meals (приёмы пищи — напоминания по времени суток)
addMeal: 'meal:add',
updateMeal: 'meal:update',
deleteMeal: 'meal:delete',
toggleMeal: 'meal:toggle',
markMealDone: 'meal:markDone',
updateSettings: 'settings:update',
getAccentColor: 'system:accentColor',
getOsTheme: 'system:osTheme',
@@ -60,6 +67,7 @@ export const IPC = {
// events from main → renderer
evtTick: 'evt:tick',
evtFire: 'evt:fire',
evtFireMeal: 'evt:fireMeal',
evtMatchEnd: 'evt:matchEnd',
evtStateChanged: 'evt:stateChanged',
evtThemeChanged: 'evt:themeChanged',

70
src/shared/meals.test.ts Normal file
View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest'
import { nextMealOccurrence } from './types'
/**
* Тесты планирования приёмов пищи по времени суток. Используем фиксированную
* «отправную точку» в локальном времени; helper тоже работает в локальном TZ,
* поэтому тесты детерминированы независимо от таймзоны CI.
*
* 2026-01-15 — четверг (getDay() === 4).
*/
const THU_10_00 = new Date(2026, 0, 15, 10, 0, 0, 0).getTime()
const THU_14_00 = new Date(2026, 0, 15, 14, 0, 0, 0).getTime()
const DAY_MS = 24 * 60 * 60 * 1000
describe('nextMealOccurrence', () => {
it('возвращает сегодняшнее время, если оно ещё не наступило', () => {
const r = new Date(nextMealOccurrence('13:00', [], THU_10_00))
expect(r.getDate()).toBe(15)
expect(r.getHours()).toBe(13)
expect(r.getMinutes()).toBe(0)
})
it('переносит на завтра, если время сегодня уже прошло', () => {
const r = new Date(nextMealOccurrence('08:00', [], THU_10_00))
expect(r.getDate()).toBe(16)
expect(r.getHours()).toBe(8)
})
it('всегда строго в будущем относительно from', () => {
expect(nextMealOccurrence('13:00', [], THU_10_00)).toBeGreaterThan(
THU_10_00
)
expect(nextMealOccurrence('08:00', [], THU_10_00)).toBeGreaterThan(
THU_10_00
)
})
it('учитывает фильтр дней недели (только пятница)', () => {
// Четверг 10:00, напоминание 13:00, дни = [5] (пятница) → завтра 16-е.
const r = new Date(nextMealOccurrence('13:00', [5], THU_10_00))
expect(r.getDate()).toBe(16)
expect(r.getDay()).toBe(5)
expect(r.getHours()).toBe(13)
})
it('сегодня входит в фильтр и время не прошло → сегодня', () => {
const r = new Date(nextMealOccurrence('13:00', [4], THU_10_00))
expect(r.getDate()).toBe(15)
expect(r.getDay()).toBe(4)
})
it('единственный день недели, время прошло → следующая неделя', () => {
// Четверг 14:00, 13:00 уже прошло, дни = [4] → следующий четверг 22-е.
const r = new Date(nextMealOccurrence('13:00', [4], THU_14_00))
expect(r.getDate()).toBe(22)
expect(r.getDay()).toBe(4)
})
it('пустой массив дней = каждый день', () => {
const r = new Date(nextMealOccurrence('23:59', [], THU_10_00))
expect(r.getDate()).toBe(15)
})
it('малформированное время → +24ч (safety)', () => {
expect(nextMealOccurrence('99:99', [], THU_10_00)).toBe(THU_10_00 + DAY_MS)
expect(nextMealOccurrence('not-a-time', [], THU_10_00)).toBe(
THU_10_00 + DAY_MS
)
})
})

View File

@@ -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 }