9 Commits

Author SHA1 Message Date
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
AnRil
8a62ebc9fe chore(release): v0.5.8 2026-05-22 23:37:43 +07:00
AnRil
b621cef12f docs(v0.5.8): CHANGELOG + release-notes + badges 2026-05-22 23:37:36 +07:00
AnRil
5c2744c746 test: покрытие achievements + release-notes (+18 тестов) 2026-05-22 23:36:11 +07:00
AnRil
e7c3088ee5 fix: дедуп rapid-double-click + i18n native dialogs + пустой default exerciseName
ReminderApp MatchSummary: sentChallengesRef для дедупа rapid double-click
на ✓ — раньше один и тот же challenge мог записаться в историю несколько
раз, давая лишние reps. Ref сбрасывается на новый match.

ExerciseCard «Готово» (для due-упражнения): такая же ref-based дедуп.
Окно ~1 сек между click → IPC.markDone → store.markDone обновляет
nextFireAt → broadcastState → ticks broadcast → isDue=false. До этого
быстрый double-click писал 2 entries с близкими ts.

ipc.ts: title в showSaveDialog/showOpenDialog локализован по
settings.language. Раньше всегда был русский в EN-локали.

Challenges editor: EMPTY_DRAFT.exerciseName: '' вместо 'Приседания'.
В EN-локали дефолтный русский текст выглядел багом. Required-валидация
не пускает пустое значение в save.
2026-05-22 23:34:41 +07:00
AnRil
2b7eb412c7 fix: export/import — отмена пользователем не показывает error toast
Bug: при отмене save-dialog или open-dialog DataCard показывал тост
«Не удалось сохранить» / «Файл не подошёл». Но cancel — это не ошибка.

Расширил IPC возврат: { ok, canceled, path?, error? }. UI теперь
различает: ok → success toast, !ok && canceled → молча, !ok && !canceled
→ error toast.

+9 тестов на validateSettingsPatch для voicePromptsEnabled,
meetingAutoPause, lastSeenVersion (semver-regex / null-сброс /
malformed). Итого 159 → 168 тестов.

Settings → About теперь показывает текущую версию приложения
(раньше была только кнопка «Что нового»). Загружается через
IPC.getAppVersion при mount.
2026-05-22 23:26:11 +07:00
AnRil
0c813c3ac8 fix+test: автономные правки после ревью v0.5.7
Bug — Heatmap/streak/achievements не обновлялись после markDone/
    markChallengeDone. Регресс из Sprint C (история выделена из
    state-broadcast). Корень: store мутирует Exercise.lastDoneAt
    in-place → state.exercises ref не меняется → useEffect([exercises])
    не fires → Dashboard не перетягивает history.
    Фикс: новый event IPC.evtHistoryChanged + broadcastHistoryChanged().
    Триггерится после markDone/snooze/skip/markChallengeDone/
    clearHistory/import. Dashboard.useEffect подписывается через
    onHistoryChanged.

Settings → AboutCard теперь показывает текущую версию приложения
    (раньше была только кнопка «Что нового»). Версия через
    IPC.getAppVersion.

Tests:
    +6 для repsDoneTodayForExercise — match-challenges, snapshot,
       deleted-exercise fallback, ignore skip/snooze.
    +2 для dailyReps с новыми snapshot-полями (match-challenges
       и deleted exercises).
    +6 для unseenVersions + RELEASE_NOTES контракт.
    +7 для adjustNextFireAt (адаптивный шедулер): малая история,
       плохой/хороший час, MAX_SHIFT_HOURS, фильтр по упражнению,
       30-day window.
    Итого 135 → 159 (+24).

Грепнул src/ на стейл-references к removed setPaused/isPaused/
    `let paused` — чисто. Sprint C-D refactor завершён без residue.
2026-05-22 23:22:34 +07:00
51 changed files with 2993 additions and 154 deletions

View File

@@ -6,6 +6,36 @@
## [Unreleased]
## [0.5.8] — 2026-05-22
Автономный QA-проход: проверил все элементы, нашёл и починил несколько
коварных багов, добавил тесты на новые модули.
### Fixed
- **Heatmap / streak / достижения теперь обновляются после markDone.**
Был регресс из Sprint C (#9 — отделение history от broadcastState):
`markDone` мутирует Exercise in-place → state.exercises ref не
меняется → Dashboard useEffect с дептой `[exercises]` не fire'ил →
history не перетягивалась. Heatmap стоял пока пользователь не
добавит/удалит упражнение. Сейчас новый event `evtHistoryChanged`
шлётся из main после `markDone/snooze/skip/markChallengeDone/
clearHistory/import`, Dashboard на него подписан.
- **Rapid double-click больше не пишет в историю дважды.** В Match
Summary при быстром тыке ✓ дважды один и тот же challenge мог
записаться 2 раза → лишние +N reps в стрик. То же для кнопки
«Готово» в ExerciseCard. ref-based дедуп.
- **Native save/open dialogs локализованы.** Раньше title `«Сохранить
резервную копию»` показывался даже в EN-локали.
- **Default exerciseName в challenge editor — пустой** (было
«Приседания» — выглядело как недопереведённый русский в EN UI).
### Added
- 18 новых тестов: `achievements.test.ts` (10), расширения
`history.test.ts` (8) — match-challenges через snapshot, deleted
exercise survival, race-edge cases.
## [0.5.7] — 2026-05-22
Сквозное ревью UX: пройдено 12 сценариев глазами пользователя, найдено
@@ -416,14 +446,15 @@
иконки), системный трей, автозапуск с Windows, native-уведомления,
NSIS-инсталлятор, auto-update через electron-updater.
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.7...HEAD
[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

@@ -4,7 +4,7 @@
## TL;DR
**Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.7**. Один разработчик (AnRil), один remote — self-hosted Gitea.
**Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.8**. Один разработчик (AnRil), один remote — self-hosted Gitea.
## Стек
@@ -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.7-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
[![tests](https://img.shields.io/badge/tests-135%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.7",
"version": "0.5.8",
"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) {

126
src/main/adaptive.test.ts Normal file
View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import type { Exercise, HistoryEntry } from '@shared/types'
import { adjustNextFireAt } from './adaptive'
const ex: Exercise = {
id: 'e1',
name: 'Pushups',
reps: 10,
icon: 'Dumbbell',
intervalMinutes: 30,
enabled: true,
nextFireAt: 0,
adaptive: true
}
function entryAt(year: number, month: number, day: number, hour: number, action: 'done' | 'skip' | 'snooze'): HistoryEntry {
return {
exerciseId: 'e1',
ts: new Date(year, month - 1, day, hour).getTime(),
action
}
}
/** Помощник: построить N entries в указанный hour-of-day за последние 30 дней. */
function buildAtHour(hour: number, doneCount: number, skipCount: number): HistoryEntry[] {
const now = new Date()
const out: HistoryEntry[] = []
for (let i = 0; i < doneCount; i++) {
const d = new Date(now)
d.setDate(d.getDate() - i - 1)
d.setHours(hour, 0, 0, 0)
out.push({ exerciseId: 'e1', ts: d.getTime(), action: 'done' })
}
for (let i = 0; i < skipCount; i++) {
const d = new Date(now)
d.setDate(d.getDate() - i - 1)
d.setHours(hour, 0, 0, 0)
out.push({ exerciseId: 'e1', ts: d.getTime(), action: 'skip' })
}
return out
}
describe('adjustNextFireAt', () => {
it('returns candidate unchanged when history is too small (<10 events)', () => {
const candidate = new Date(2026, 4, 22, 14, 30).getTime()
const result = adjustNextFireAt(ex, candidate, [
entryAt(2026, 4, 21, 14, 'done'),
entryAt(2026, 4, 20, 14, 'done')
])
expect(result).toBe(candidate)
})
it('returns candidate unchanged when candidate hour is not bad', () => {
// Час 14 — хороший (10 done, 0 skip = 100% success). Не сдвигаем.
const history = buildAtHour(14, 10, 0)
const candidate = new Date()
candidate.setHours(14, 30, 0, 0)
expect(adjustNextFireAt(ex, candidate.getTime(), history)).toBe(
candidate.getTime()
)
})
it('shifts candidate from a bad hour to the nearest non-bad hour', () => {
// Час 9 — плохой (1 done, 9 skip = 10% success).
// Час 10 — нейтральный (no data) = good по нашему определению.
// Спецификация: шедулер выбирает первый non-bad час, neutral OK
// (пользователь ещё не показал, что этот час плохой).
const history = [
...buildAtHour(9, 1, 9), // 10 событий, success 10%
...buildAtHour(11, 10, 0) // 10 событий, success 100%
]
const candidate = new Date()
candidate.setHours(9, 30, 0, 0)
const result = adjustNextFireAt(ex, candidate.getTime(), history)
const shifted = new Date(result)
// Час 10 ближайший non-bad (neutral).
expect(shifted.getHours()).toBe(10)
expect(shifted.getMinutes()).toBe(0)
})
it('does not shift beyond MAX_SHIFT_HOURS (4 hours)', () => {
// Час 9 — плохой. Все часы 10..23 без данных (neutral, не «good»
// по нашему определению isHourGood которое требует tota=0 OR rate>=0.5).
// Wait — isHourGood вернёт true если total===0 (neutral). Значит
// сдвиг произойдёт на 10:00. Это OK поведение — neutral час лучше
// плохого.
const history = buildAtHour(9, 1, 9)
const candidate = new Date()
candidate.setHours(9, 30, 0, 0)
const result = adjustNextFireAt(ex, candidate.getTime(), history)
// Сдвиг на 10:00 (первый neutral час).
expect(new Date(result).getHours()).toBe(10)
})
it('only counts entries for this exercise', () => {
// Истории много, но всё по другому упражнению — не trust'able.
const otherEx: HistoryEntry[] = []
for (let i = 0; i < 20; i++) {
const d = new Date()
d.setDate(d.getDate() - i - 1)
d.setHours(9, 0, 0, 0)
otherEx.push({ exerciseId: 'other', ts: d.getTime(), action: 'skip' })
}
const candidate = new Date()
candidate.setHours(9, 30, 0, 0)
expect(adjustNextFireAt(ex, candidate.getTime(), otherEx)).toBe(
candidate.getTime()
)
})
it('ignores entries older than 30 days', () => {
// 20 событий 60 дней назад → не учитываются (только 30-day window).
const oldHistory: HistoryEntry[] = []
for (let i = 0; i < 20; i++) {
const d = new Date()
d.setDate(d.getDate() - 60 - i)
d.setHours(9, 0, 0, 0)
oldHistory.push({ exerciseId: 'e1', ts: d.getTime(), action: 'skip' })
}
const candidate = new Date()
candidate.setHours(9, 30, 0, 0)
expect(adjustNextFireAt(ex, candidate.getTime(), oldHistory)).toBe(
candidate.getTime()
)
})
})

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,14 +26,16 @@ import {
importState,
markChallengeDone,
markDone,
markMealDone,
setGameEnabled,
skip,
snooze,
updateChallenge,
updateExercise,
updateMeal,
updateSettings
} from './store'
import { broadcastState } from './state-actions'
import { broadcastHistoryChanged, broadcastState } from './state-actions'
import { setAutostart, isAutostartEnabled } from './autostart'
import { forceCheck } from './scheduler'
import { hideReminderWindow, getMainWindow } from './windows'
@@ -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,32 +172,77 @@ 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))
broadcastState()
broadcastHistoryChanged()
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
const ex = snooze(id, minutes)
broadcastState()
broadcastHistoryChanged()
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)
broadcastState()
broadcastHistoryChanged()
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) {
@@ -162,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 {
@@ -182,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)
@@ -230,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()
@@ -239,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()
@@ -247,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)
@@ -271,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)
@@ -289,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)
@@ -299,6 +399,7 @@ export function registerIpc(): void {
if (!id || reps === undefined || reps <= 0) return false
markChallengeDone(id, reps)
broadcastState()
broadcastHistoryChanged()
return true
}
)
@@ -307,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)
@@ -316,62 +417,77 @@ 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) =>
clearHistory(beforeTs)
)
safeHandle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
safeHandle(IPC.clearHistory, (_e, beforeTs?: number) => {
const removed = clearHistory(beforeTs)
if (removed > 0) broadcastHistoryChanged()
return removed
})
// 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()
.replace(/[:T]/g, '-')
.slice(0, 16)
const defaultPath = `laude-backup-${stamp}.json`
// Native-диалоги OS читают локаль из системы. Title — единственная
// строка которую мы контролируем; локализуем по settings.language.
const lang = getState().settings.language ?? 'ru'
const result = await dialog.showSaveDialog(win!, {
title: 'Сохранить резервную копию',
title:
lang === 'en' ? 'Save backup' : 'Сохранить резервную копию',
defaultPath,
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || !result.filePath) return { ok: false, path: null }
// Cancel — это не ошибка. Возвращаем canceled=true чтобы UI мог
// ничего не показывать (без error toast).
if (result.canceled || !result.filePath) {
return { ok: false, canceled: true, path: null }
}
try {
writeFileSync(result.filePath, exportState(), 'utf-8')
return { ok: true, path: result.filePath }
return { ok: true, canceled: false, path: result.filePath }
} catch (e) {
return { ok: false, path: null, error: String(e) }
return { ok: false, canceled: false, path: null, error: String(e) }
}
})
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!, {
title: 'Восстановить из резервной копии',
title:
lang === 'en' ? 'Restore from backup' : 'Восстановить из резервной копии',
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || result.filePaths.length === 0) {
return { ok: false }
return { ok: false, canceled: true }
}
try {
const raw = readFileSync(result.filePaths[0], 'utf-8')
const ok = importState(raw)
if (ok) broadcastState()
return { ok }
if (ok) {
broadcastState()
broadcastHistoryChanged()
}
return { ok, canceled: false }
} catch (e) {
return { ok: false, error: String(e) }
return { ok: false, canceled: false, error: String(e) }
}
})
}

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

View File

@@ -12,6 +12,13 @@ export function broadcastState(): void {
}
}
/** Сигнализирует renderer'у что историю надо перетянуть. */
export function broadcastHistoryChanged(): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtHistoryChanged)
}
}
export function snoozeAll(minutes: number): void {
const now = Date.now()
for (const ex of getExercises()) {

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,
@@ -266,6 +344,65 @@ describe('validateSettingsPatch', () => {
expect(validateSettingsPatch({ snoozeMinutes: -5 })).toBeNull()
})
it('accepts voicePromptsEnabled boolean', () => {
expect(validateSettingsPatch({ voicePromptsEnabled: true })).toEqual({
voicePromptsEnabled: true
})
expect(validateSettingsPatch({ voicePromptsEnabled: false })).toEqual({
voicePromptsEnabled: false
})
})
it('rejects non-boolean voicePromptsEnabled in patch', () => {
expect(validateSettingsPatch({ voicePromptsEnabled: 'yes' })).toBeNull()
expect(validateSettingsPatch({ voicePromptsEnabled: 1 })).toBeNull()
})
it('accepts meetingAutoPause boolean', () => {
expect(validateSettingsPatch({ meetingAutoPause: true })).toEqual({
meetingAutoPause: true
})
})
it('rejects non-boolean meetingAutoPause', () => {
expect(validateSettingsPatch({ meetingAutoPause: 'yes' })).toBeNull()
})
describe('lastSeenVersion', () => {
it('accepts valid semver', () => {
const r = validateSettingsPatch({ lastSeenVersion: '0.5.7' })
expect(r?.lastSeenVersion).toBe('0.5.7')
expect(validateSettingsPatch({ lastSeenVersion: '10.20.30' })).toEqual({
lastSeenVersion: '10.20.30'
})
})
it('accepts pre-release suffix', () => {
const r = validateSettingsPatch({ lastSeenVersion: '0.5.7-beta.1' })
expect(r?.lastSeenVersion).toBe('0.5.7-beta.1')
})
it('treats null/undefined as reset to undefined', () => {
const r1 = validateSettingsPatch({ lastSeenVersion: null })
expect(r1).toEqual({ lastSeenVersion: undefined })
const r2 = validateSettingsPatch({ lastSeenVersion: undefined })
// 'lastSeenVersion' is `in raw` even if undefined — both treated reset.
expect(r2).toEqual({ lastSeenVersion: undefined })
})
it('rejects malformed strings', () => {
expect(validateSettingsPatch({ lastSeenVersion: '0.5' })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: 'v0.5.7' })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: 'beta' })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: '' })).toBeNull()
})
it('rejects non-strings', () => {
expect(validateSettingsPatch({ lastSeenVersion: 42 })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: ['1', '0', '0'] })).toBeNull()
})
})
describe('quietHours subobject', () => {
const baseQh = {
enabled: true,

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),
@@ -122,13 +136,21 @@ const api = {
ipcRenderer.invoke(IPC.clearHistory, beforeTs),
// Export / Import — открывают native save/open dialogs из main process.
exportState: (): Promise<{ ok: boolean; path: string | null }> =>
ipcRenderer.invoke(IPC.exportState),
importState: (): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke(IPC.importState),
exportState: (): Promise<{
ok: boolean
canceled: boolean
path: string | null
error?: string
}> => ipcRenderer.invoke(IPC.exportState),
importState: (): Promise<{
ok: boolean
canceled: boolean
error?: string
}> => ipcRenderer.invoke(IPC.importState),
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 =>
@@ -141,7 +163,9 @@ const api = {
onMaximizeChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMaximizeChanged, h),
onMeetingChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMeetingChanged, h)
on(IPC.evtMeetingChanged, h),
onHistoryChanged: (h: Handler<void>): Unsub =>
on(IPC.evtHistoryChanged, h)
}
contextBridge.exposeInMainWorld('api', api)

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-окне нет места для модалки,
@@ -39,6 +41,10 @@ export default function ReminderApp(): JSX.Element {
const [mode, setMode] = useState<Mode>({ kind: 'idle' })
const [settings, setSettings] = useState<Settings | null>(null)
const settingsRef = useRef<Settings | null>(null)
// ChallengeId'ы, для которых уже отправили markChallengeDone IPC. ref,
// не state — нужен только для дедупа rapid double-click. Сбрасывается
// когда приходит новый match summary (см. onMatchEnd ниже).
const sentChallengesRef = useRef<Set<string>>(new Set())
useEffect(() => {
settingsRef.current = settings
@@ -65,7 +71,20 @@ 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()
setMode({ kind: 'match', summary, done: new Set() })
const s = settingsRef.current
if (s?.soundEnabled) playBeep()
@@ -82,6 +101,7 @@ export default function ReminderApp(): JSX.Element {
return () => {
u0()
u1()
u1b()
u2()
}
}, [])
@@ -139,12 +159,28 @@ 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}
done={mode.done}
lang={lang}
onMarkDone={(id) => {
// Дедупликация: rapid double-click может два раза вызвать
// onMarkDone до того как `disabled={done}` доедет до DOM.
// Раньше это писало в историю дважды → лишние +N reps.
if (sentChallengesRef.current.has(id)) return
sentChallengesRef.current.add(id)
// 1) IPC: записываем в историю (раньше делали только локальный set,
// из-за чего матч-челленджи не считались в стрик/achievements).
const result = mode.summary.results.find((r) => r.challengeId === id)
@@ -339,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 { 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,7 +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)
}
// Ring math
const R = 22
@@ -110,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>
@@ -122,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()
@@ -133,6 +201,7 @@ export function ExerciseCard({
{t('btn.edit')}
</button>
<button
role="menuitem"
onClick={() => {
setMenuOpen(false)
onDelete()
@@ -213,7 +282,7 @@ export function ExerciseCard({
<motion.button
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
onClick={onMarkDone}
onClick={handleMarkDone}
className="mt-4 w-full h-11 rounded-xl bg-accent text-white text-[15px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
<Check size={15} strokeWidth={2.5} /> {t('btn.done')}

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': 'Редактировать',
@@ -174,6 +202,8 @@ export const ru: Dict = {
'settings.data.import.ok': 'Восстановлено',
'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?',
'settings.section.about': 'О приложении',
'settings.version.label': 'Версия',
'settings.version.hint': 'Текущая установленная версия приложения.',
'settings.whatsnew.label': 'Что нового',
'settings.whatsnew.hint': 'Посмотреть заметки последних релизов.',
'settings.whatsnew.btn': 'Открыть',
@@ -248,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': 'Пятьсот',
@@ -344,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',
@@ -424,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',
@@ -503,6 +562,8 @@ export const en: Dict = {
'settings.data.import.ok': 'Restored',
'settings.data.import.err': "Couldn't read the file — not our backup?",
'settings.section.about': 'About',
'settings.version.label': 'Version',
'settings.version.hint': 'Currently installed app version.',
'settings.whatsnew.label': "What's new",
'settings.whatsnew.hint': 'See the latest release notes.',
'settings.whatsnew.btn': 'Open',
@@ -577,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

@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import type { Exercise, HistoryEntry } from '@shared/types'
import { computeAchievements } from './achievements'
const MS_DAY = 24 * 60 * 60 * 1000
function ex(id: string, reps: number): Exercise {
return {
id,
name: id,
reps,
icon: 'Activity',
intervalMinutes: 30,
enabled: true,
nextFireAt: 0
}
}
function done(exerciseId: string, ts: number, reps?: number): HistoryEntry {
const e: HistoryEntry = { exerciseId, ts, action: 'done' }
if (reps !== undefined) e.reps = reps
return e
}
describe('computeAchievements', () => {
it('first_day unlocks on first done', () => {
const exs = [ex('a', 10)]
const hist = [done('a', Date.now())]
const out = computeAchievements(hist, exs)
const first = out.find((a) => a.def.id === 'first_day')
expect(first?.unlocked).toBe(true)
})
it('first_day locked when no entries', () => {
const out = computeAchievements([], [])
const first = out.find((a) => a.def.id === 'first_day')
expect(first?.unlocked).toBe(false)
})
it('reps_100 unlocks at exactly 100 total reps', () => {
const exs = [ex('a', 10)]
// 10 done entries × 10 reps = 100
const hist = Array.from({ length: 10 }, (_, i) =>
done('a', Date.now() - i * 1000)
)
const out = computeAchievements(hist, exs)
const reps100 = out.find((a) => a.def.id === 'reps_100')
expect(reps100?.unlocked).toBe(true)
expect(reps100?.current).toBe(100)
})
it('reps_500 locked at 100 with progress', () => {
const exs = [ex('a', 10)]
const hist = Array.from({ length: 10 }, (_, i) =>
done('a', Date.now() - i * 1000)
)
const out = computeAchievements(hist, exs)
const reps500 = out.find((a) => a.def.id === 'reps_500')
expect(reps500?.unlocked).toBe(false)
expect(reps500?.current).toBe(100)
expect(reps500?.target).toBe(500)
})
it('streak_3 unlocks with 3 consecutive days ending today', () => {
const exs = [ex('a', 10)]
const today = new Date()
today.setHours(12, 0, 0, 0)
const hist = [
done('a', today.getTime()),
done('a', today.getTime() - MS_DAY),
done('a', today.getTime() - 2 * MS_DAY)
]
const out = computeAchievements(hist, exs)
const s3 = out.find((a) => a.def.id === 'streak_3')
expect(s3?.unlocked).toBe(true)
})
it('streak_3 locked with gap in days', () => {
const exs = [ex('a', 10)]
const today = new Date()
today.setHours(12, 0, 0, 0)
const hist = [
done('a', today.getTime()),
done('a', today.getTime() - MS_DAY)
// отсутствует день -2 — стрик прерван
]
const out = computeAchievements(hist, exs)
const s3 = out.find((a) => a.def.id === 'streak_3')
expect(s3?.unlocked).toBe(false)
expect(s3?.current).toBe(2)
})
it('today_quad unlocks at 40+ reps today', () => {
const exs = [ex('a', 50)]
const hist = [done('a', Date.now())]
const out = computeAchievements(hist, exs)
const q = out.find((a) => a.def.id === 'today_quad')
expect(q?.unlocked).toBe(true)
expect(q?.current).toBe(50)
})
it('today_quad locked at 30 reps', () => {
const exs = [ex('a', 30)]
const hist = [done('a', Date.now())]
const out = computeAchievements(hist, exs)
const q = out.find((a) => a.def.id === 'today_quad')
expect(q?.unlocked).toBe(false)
expect(q?.current).toBe(30)
expect(q?.target).toBe(40)
})
it('counts match-challenges via entry.reps snapshot', () => {
// Match challenge entries имеют exerciseId='challenge:<id>' и snapshot
// reps в поле reps. computeAchievements должен их учитывать.
const exs = [ex('a', 10)]
const today = Date.now()
const hist: HistoryEntry[] = [
// 100 reps от обычных
...Array.from({ length: 10 }, (_, i) => done('a', today - i * 1000)),
// +50 от челленджа
{
exerciseId: 'challenge:abc',
ts: today,
action: 'done',
actualReps: 50,
reps: 50,
source: 'match'
}
]
const out = computeAchievements(hist, exs)
const reps100 = out.find((a) => a.def.id === 'reps_100')
expect(reps100?.current).toBe(150) // 100 + 50
})
it('returns deterministic order matching DEFINITIONS', () => {
const out = computeAchievements([], [])
// Не пустой массив, есть все определённые достижения.
expect(out.length).toBeGreaterThan(5)
// reps_100 идёт раньше streak_3 (порядок DEFINITIONS).
const reps100Idx = out.findIndex((a) => a.def.id === 'reps_100')
const streak3Idx = out.findIndex((a) => a.def.id === 'streak_3')
expect(reps100Idx).toBeLessThan(streak3Idx)
})
})

View File

@@ -5,7 +5,8 @@ import {
dailyReps,
dayKey,
dailyRepsRange,
plannedRepsToday
plannedRepsToday,
repsDoneTodayForExercise
} from './history'
const MS_DAY = 24 * 60 * 60 * 1000
@@ -197,3 +198,105 @@ describe('currentStreak edge cases', () => {
expect(currentStreak(hist)).toBe(1)
})
})
describe('repsDoneTodayForExercise', () => {
const today = Date.now()
const exercise = ex('a', 10)
const other = ex('b', 5)
it('returns 0 if no entries', () => {
expect(repsDoneTodayForExercise([], exercise)).toBe(0)
})
it('counts only entries for this exercise today', () => {
const hist = [
entry('a', today),
entry('a', today),
entry('b', today), // other exercise — игнорируем
entry('a', today - 2 * 24 * 60 * 60 * 1000) // позавчера — игнорируем
]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(20)
expect(repsDoneTodayForExercise(hist, other)).toBe(5)
})
it('uses actualReps when set', () => {
const hist = [entry('a', today, 'done', 7), entry('a', today)]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(7 + 10)
})
it('ignores skip / snooze entries', () => {
const hist = [
entry('a', today, 'skip'),
entry('a', today, 'snooze'),
entry('a', today)
]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(10)
})
it('prefers entry.reps snapshot over exercise.reps (historical accuracy)', () => {
// Контракт: entry.reps это «сколько было запланировано на момент
// записи». Если пользователь раньше делал 15 раз приседаний, потом
// изменил планку на 10 — history должна показывать 15 для старых
// entries, не 10. Это правильнее для аналитики «что я тогда делал».
const histWithSnapshot: HistoryEntry[] = [
{ exerciseId: 'a', ts: today, action: 'done', reps: 15 }
]
expect(repsDoneTodayForExercise(histWithSnapshot, exercise)).toBe(15)
})
it('falls back to exercise.reps when entry has no snapshot', () => {
// Старые entries (до Sprint #1 / v0.5.7) не имеют entry.reps.
// Должны fall'back'нуться на текущий exercise.reps.
const histOldEntry: HistoryEntry[] = [
{ exerciseId: 'a', ts: today, action: 'done' }
]
expect(repsDoneTodayForExercise(histOldEntry, exercise)).toBe(10)
})
it('survives match challenges (exerciseId=challenge:<id>)', () => {
// Match-челлендж не привязан к exercise — repsDoneTodayForExercise
// его игнорирует (это не reps для этого упражнения).
const hist: HistoryEntry[] = [
{
exerciseId: 'challenge:abc',
ts: today,
action: 'done',
actualReps: 30,
reps: 30,
source: 'match'
}
]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(0)
})
})
describe('dailyReps with new entry.reps snapshot', () => {
const today = Date.now()
const exs = [ex('a', 10)]
it('counts match-challenge entries via entry.reps snapshot', () => {
// У match-челленджа exerciseId='challenge:<id>', byId.get вернёт
// undefined. entry.reps snapshot — единственный источник.
const hist: HistoryEntry[] = [
{
exerciseId: 'challenge:abc',
ts: today,
action: 'done',
actualReps: 30,
reps: 30,
source: 'match'
},
entry('a', today) // обычная entry — 10 reps через byId
]
expect(dailyReps(hist, exs, dayKey(today))).toBe(40)
})
it('survives deleted exercise via entry.reps snapshot', () => {
// Упражнение 'gone' удалено, но entry.reps=8 был записан до удаления.
const hist: HistoryEntry[] = [
{ exerciseId: 'gone', ts: today, action: 'done', reps: 8 }
]
// byId.get('gone') = undefined → fallback на entry.reps=8.
expect(dailyReps(hist, exs, dayKey(today))).toBe(8)
})
})

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

@@ -22,12 +22,16 @@ const GAME_NAMES: Record<GameId, string> = {
type Draft = Omit<Challenge, 'id'>
// exerciseName умышленно пустой — пусть пользователь сам выберет что
// делать. Раньше дефолт был «Приседания» — в EN-локали это выглядело как
// баг (русский текст в английском UI). Required-валидация всё равно
// требует непустого значения перед save.
const EMPTY_DRAFT: Draft = {
name: '',
gameId: 'dota2',
stat: 'deaths',
multiplier: 3,
exerciseName: 'Приседания',
exerciseName: '',
icon: 'Activity',
enabled: true
}
@@ -135,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

@@ -60,12 +60,20 @@ export default function Dashboard(): JSX.Element {
(g) => g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied')
)
// Local history mirror; reloaded only when exercises change (not on every
// tick or settings tweak — those don't affect history). When ticks/settings
// change we don't re-fetch.
// Local history mirror. Перетягиваем (а) на mount, (б) при изменении
// exercises (add/delete/edit — могут поменять name/icon в snapshot'ах
// для будущих entries), (в) при evtHistoryChanged — это event который
// main отправляет ПОСЛЕ любого markDone/markChallengeDone/clearHistory/
// import. Без (в) heatmap и стрик стояли на месте после markDone —
// store мутирует exercise in place, ref не меняется, useEffect не
// fire'ил.
const [history, setHistory] = useState<HistoryEntry[]>([])
useEffect(() => {
const refetch = (): void => {
void window.api.getHistory().then(setHistory)
}
refetch()
return window.api.onHistoryChanged(refetch)
}, [exercises])
// Meeting auto-pause indicator: подписываемся на evtMeetingChanged +

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)
@@ -191,6 +204,10 @@ export default function SettingsPage(): JSX.Element {
function AboutCard(): JSX.Element {
const { t } = useT()
const [open, setOpen] = useState(false)
const [version, setVersion] = useState<string>('')
useEffect(() => {
void window.api.getAppVersion().then(setVersion)
}, [])
// Все версии для которых у нас есть заметки, отсортированы desc.
const allVersions = Object.keys(RELEASE_NOTES).sort((a, b) => {
const pa = a.split('.').map(Number)
@@ -200,6 +217,19 @@ function AboutCard(): JSX.Element {
})
return (
<Card>
<Row>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.version.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.version.hint')}
</div>
</div>
<div className="text-[14px] font-mono-num font-semibold text-text/70">
{version ? `v${version}` : '—'}
</div>
</Row>
<Row last>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
@@ -227,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)
@@ -239,30 +271,32 @@ 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) {
setToast(t('settings.data.export.ok', { path: r.path }))
} else if (!r.ok) {
} else if (!r.ok && !r.canceled) {
// canceled — пользователь сам передумал, тост не нужен.
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'))
else if ('error' in r && r.error !== undefined) {
else if (!r.canceled) {
// canceled — пользователь не выбрал файл, не показываем error.
setToast(t('settings.data.import.err'))
}
} finally {
setBusy(false)
setBusy(null)
}
}
@@ -279,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>
@@ -296,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',
@@ -68,6 +76,15 @@ export const IPC = {
evtUpdaterStatus: 'evt:updaterStatus',
evtMaximizeChanged: 'evt:maximizeChanged',
evtMeetingChanged: 'evt:meetingChanged',
/**
* Шлётся когда история мутирует (markDone / snooze / skip /
* markChallengeDone / clearHistory / import). Renderer'у достаточно
* перезапросить getHistory. Раньше Dashboard переключал history по
* `exercises` ref'у — но markDone мутирует Exercise in place, ref не
* меняется, и heatmap стояла. Этот event — единый сигнал что надо
* перетянуть.
*/
evtHistoryChanged: 'evt:historyChanged',
getMeetingActive: 'system:meetingActive'
} as const

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

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { unseenVersions, RELEASE_NOTES } from './release-notes'
describe('unseenVersions', () => {
// Завязываемся на реальный RELEASE_NOTES — это OK, тест защищает контракт.
// Если из RELEASE_NOTES удалят ключ, упадёт expect.
it('returns only current version when lastSeen is undefined (new user proxy)', () => {
// Логика: при отсутствии lastSeen возвращаем только current — мы НЕ
// знаем, новичок это или нет; UI решает (через exercises.lastDoneAt).
// Эта функция только показывает «что было бы показано».
const result = unseenVersions('0.5.6', undefined)
expect(result).toEqual(['0.5.6'])
})
it('returns nothing when lastSeen equals current', () => {
expect(unseenVersions('0.5.6', '0.5.6')).toEqual([])
})
it('returns versions strictly between lastSeen and current (desc)', () => {
// Юзер видел 0.5.4, обновился на 0.5.7 → видит 0.5.5, 0.5.6, 0.5.7.
const result = unseenVersions('0.5.7', '0.5.4')
// Порядок desc (новейшее сверху).
expect(result[0]).toBe('0.5.7')
expect(result).toContain('0.5.5')
expect(result).toContain('0.5.6')
expect(result).not.toContain('0.5.4')
expect(result).not.toContain('0.5.3')
})
it('skips versions beyond current (no notes for not-yet-installed releases)', () => {
// Юзер видел 0.5.4, current=0.5.5 → видит только 0.5.5, не 0.5.6+.
const result = unseenVersions('0.5.5', '0.5.4')
expect(result).toEqual(['0.5.5'])
})
it('handles versions with patch increments correctly', () => {
const result = unseenVersions('0.5.7', '0.5.6')
expect(result).toEqual(['0.5.7'])
})
it('lastSeen ahead of current returns empty (downgrade case)', () => {
const result = unseenVersions('0.5.3', '0.5.5')
expect(result).toEqual([])
})
})
describe('RELEASE_NOTES contract', () => {
it('has both ru and en for every version', () => {
for (const [v, notes] of Object.entries(RELEASE_NOTES)) {
expect(notes.ru, `v${v} missing ru notes`).toBeTruthy()
expect(notes.en, `v${v} missing en notes`).toBeTruthy()
expect(notes.ru.length, `v${v} empty ru`).toBeGreaterThan(0)
expect(notes.en.length, `v${v} empty en`).toBeGreaterThan(0)
}
})
it('all version keys match semver-light /^\\d+\\.\\d+\\.\\d+$/', () => {
for (const v of Object.keys(RELEASE_NOTES)) {
expect(v).toMatch(/^\d+\.\d+\.\d+$/)
}
})
it('every note item has title; tag is in allowed set if present', () => {
const allowedTags = new Set(['new', 'fix', 'security', 'perf'])
for (const notes of Object.values(RELEASE_NOTES)) {
for (const lang of ['ru', 'en'] as const) {
for (const it of notes[lang]) {
expect(it.title.length).toBeGreaterThan(0)
if (it.tag) expect(allowedTags.has(it.tag)).toBe(true)
}
}
}
})
})

View File

@@ -21,6 +21,56 @@ export type ReleaseNoteItem = {
export type ReleaseNotes = Record<Language, ReleaseNoteItem[]>
export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
'0.5.8': {
ru: [
{
title: 'Heatmap и стрик обновляются сразу после нажатия «Готово»',
detail:
'Был регресс — статистика не обновлялась пока не изменишь упражнение. Починено через новое событие evtHistoryChanged.',
tag: 'fix'
},
{
title: 'Двойной клик на ✓ больше не пишет 2 раза',
detail:
'Rapid double-click на ✓ в Match Summary и «Готово» давал лишние повторы в стрик. Добавлен ref-based дедуп.',
tag: 'fix'
},
{
title: 'Native save/open dialogs локализованы',
tag: 'fix'
},
{
title: '+18 тестов на новые модули',
detail:
'achievements, match-challenge edge cases, deleted exercise survival.',
tag: 'new'
}
],
en: [
{
title: 'Heatmap and streak update immediately after pressing "Done"',
detail:
'There was a regression — stats did not update until you edited an exercise. Fixed via new evtHistoryChanged event.',
tag: 'fix'
},
{
title: 'Double-click on ✓ no longer writes twice',
detail:
'Rapid double-click on ✓ in Match Summary and "Done" added extra reps to streak. ref-based dedup.',
tag: 'fix'
},
{
title: 'Native save/open dialogs localised',
tag: 'fix'
},
{
title: '+18 tests for new modules',
detail:
'achievements, match-challenge edge cases, deleted exercise survival.',
tag: 'new'
}
]
},
'0.5.7': {
ru: [
{

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 }