25 Commits

Author SHA1 Message Date
Codex
3a93961738 chore(release): v0.6.6 2026-06-08 13:20:42 +07:00
Codex
349ce51c67 feat(settings): add status-first control center 2026-06-08 13:19:20 +07:00
Codex
544db9cb04 chore(release): v0.6.5 2026-06-07 22:49:37 +07:00
Codex
84b2bbf0a6 feat(dashboard): make overview action-first 2026-06-07 22:48:24 +07:00
Codex
ea052f64b8 chore(release): v0.6.4 2026-06-07 14:18:34 +07:00
Codex
cde8334c73 feat(ui): refresh page summaries and brand 2026-06-07 14:17:24 +07:00
Codex
deb3483f94 chore(release): v0.6.3 2026-06-07 12:09:29 +07:00
Codex
5ed80d7122 feat(dashboard): add momentum and game debt 2026-06-07 12:08:21 +07:00
Codex
baf96ca0fa chore(release): v0.6.2 2026-06-06 20:52:33 +07:00
Codex
7c40558cd3 feat(app): add diagnostics and update runtime 2026-06-06 20:42:34 +07:00
Codex
925181a3b7 feat(dashboard): add daily plan summary 2026-06-06 13:31:06 +07:00
Codex
8196bd3351 chore: force tls 1.2 for release uploads 2026-06-06 12:53:39 +07:00
Codex
b5f6952ad2 chore(release): v0.6.1 2026-06-06 12:37:53 +07:00
Codex
ffe80b62c4 fix: harden reminders and state handling 2026-06-06 02:27:04 +07:00
AnRil
ad000c722e fix(meals): плавная анимация переключателя в обе стороны
Список «Питания» разбивался на два <Card> (активные/выключенные). При
переключении строка переезжала между списками → её Switch размонтировался и
монтировался заново, и ползунок анимировался только при включении (mount
x:0→20), а при выключении — нет.

Теперь единый keyed-список (включённые сверху): Switch остаётся смонтированным,
ползунок плавно ездит в обе стороны, а строка «переезжает» в свою группу через
framer layout-анимацию. Иконке добавлен transition-colors. Глобальный Switch не
трогал — Упражнения/Дашборд не затронуты.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 00:13:07 +07:00
AnRil
c0827f887f chore(release): v0.6.0 2026-06-05 22:16:52 +07:00
AnRil
bef733a877 feat(meals): вкладка «Питание» — напоминания о еде по времени суток
Новая модель Meal — напоминание по настенным часам (time HH:MM + дни недели),
в отличие от interval-based Exercise. Отдельная вкладка «Питание» с пресетами
быстрого добавления (Завтрак/Обед/Ужин/Перекус).

- shared: тип Meal, meals в AppState, nextMealOccurrence (DST-safe), SAMPLE_MEALS,
  MEAL_PRESETS; IPC-каналы meal:* + evtFireMeal
- main: валидация (строгая HH:MM-проверка диапазона), store-мутаторы с пересчётом
  nextFireAt, scheduler.checkDueMeals (гейт только globalEnabled, grace-окно 120с,
  игнор тихих часов/ВКС), notifications.fireMealReminder, IPC-хендлеры
- renderer: вкладка Meals + MealEditor (время/дни/иконка), MealReminder в окне
  напоминания (Поел/Отложить, TTS), пункт в Sidebar, маршрут, i18n RU/EN, иконки
  UtensilsCrossed/Soup
- persistence: meals additive (без bump схемы — старые state'ы получают [])
- +24 теста (203 -> 227): nextMealOccurrence, валидаторы приёмов пищи,
  scheduler meal-gating (вкл/выкл, grace, игнор тихих часов)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:45:34 +07:00
AnRil
0cace2975d chore(remote): миграция Gitea-URL на сабдомен git.
Gitea переехал с path-prefix (xn--90adajar8af4h.xn--p1ai/git/) на
выделенный сабдомен (git.xn--90adajar8af4h.xn--p1ai). Старый URL теперь
отдаёт чужое приложение и для git мёртв.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 13:23:53 +07:00
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
74 changed files with 9983 additions and 3073 deletions

View File

@@ -6,6 +6,140 @@
## [Unreleased] ## [Unreleased]
## [0.6.6] — 2026-06-08
### Added
- Settings получили верхнюю панель состояния: сразу видно, работают ли напоминания,
включены ли тихие часы, авто-пауза встреч и запуск вместе с Windows.
- Добавлен renderer-стенд `npm run dev:renderer` с демо-данными для безопасной
визуальной проверки UI без доступа к реальным пользовательским настройкам.
### Changed
- Settings перегруппированы: язык и тема теперь находятся в одном блоке “Интерфейс”,
а главный переключатель напоминаний вынесен в раздел “Напоминания”.
- Сводные карточки на вторичных страницах больше не обрезают длинные значения и
используют более спокойную сетку для десктопных ширин.
- Sidebar теперь показывает “Напоминания на паузе”, если глобальный режим остановлен.
### Fixed
- На главном экране дата больше не получает искусственную капитализацию.
- Цели и ближайшие действия показывают единицы измерения: например “осталось 30 раз”.
- Русский статус трекинга матчей больше не показывает английское `Setup`.
## [0.6.5] — 2026-06-07
### Added
- Главный экран переосмыслен как обзор действий: верхний заголовок теперь
показывает текущее состояние (`пора сделать`, `следующее`, `встреча`,
`пауза`, `план под контролем`) вместо абстрактного “Сегодня”.
- Добавлены тесты для Windows autostart и для того, что Discord не считается
активной встречей.
### Changed
- Пункт главной навигации переименован с “Сегодня” на “Обзор”.
- Тексты meeting auto-pause стали нейтральнее: “Встреча активна”, без
формулировки “Не дёргаем — ты на встрече”.
- Discord убран из списка приложений, которые ставят напоминания на паузу.
### Fixed
- Исправлена проверка `Запускать с Windows`: чтение login item теперь использует
тот же `path` и `--hidden`, что и запись через `setLoginItemSettings`.
## [0.6.4] — 2026-06-07
### Added
- На страницах `Упражнения`, `Питание`, `Игры`, `Челленджи` и `Настройки`
добавлены верхние сводные карточки: активные элементы, нагрузка, цели,
ближайшее питание, состояние игровых интеграций и ключевые настройки.
- Добавлен общий `PageScaffold` для единых заголовков и insight-карточек в renderer.
### Changed
- Видимый бренд в интерфейсе сменен с “Не Залипай” на “Разомнись”.
- README, title, release notes и описание пакета обновлены под новое название.
## [0.6.3] — 2026-06-07
### Added
- Главный экран получил новый блок прогресса: мягкий уровень, недельные
мини-челленджи и “игровой долг” после каток.
- Добавлен `src/renderer/src/lib/momentum.ts`: вычисляемая модель ритма недели,
XP/уровня и Dota match-debt без изменения persisted state.
- Добавлены тесты `momentum.test.ts` на недельные челленджи, игровые долги и
расчет уровня.
### Changed
- Визуальный бренд в интерфейсе сменен на “Не Залипай”.
- README обновлен под новую продуктовую концепцию: план дня, недельные
челленджи, игровые долги и 241 passing tests.
## [0.6.2] — 2026-06-06
### Added
- `npm run verify` и `scripts/verify.ps1`: typecheck, tests, lint, build и
audit summary одним локальным прогоном.
- Diagnostics card в Settings: версии приложения/runtime, userData/store/log
paths, счетчики persisted state, updater/game/GSI/meeting status, открытие
папки логов и копирование diagnostics JSON.
- Renderer error reporting: `ErrorBoundary`, `window.error` и
`unhandledrejection` теперь отправляют отчеты в main logs.
- Electron security hardening: deny-by-default permission handlers и запрет
`webview` attach.
- `docs/SECURITY_TRIAGE.md` с текущим audit baseline и release rules.
### Changed
- Обновлены зависимости: Electron `42.3.3`, electron-builder `26.15.0`,
electron-vite `5.0.0`, Vite `7.3.5`, `@vitejs/plugin-react` `5.2.0`,
electron-updater `6.8.9`, esbuild `0.28.0`.
- Валидация quiet hours стала строгой в renderer и main: значения вроде
`25:00` / `09:99` отклоняются до сохранения.
### Fixed
- `npm audit` снижен с 13 vulnerabilities до 0 после staged upgrade Electron
runtime и build-chain.
## [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 ## [0.5.7] — 2026-05-22
Сквозное ревью UX: пройдено 12 сценариев глазами пользователя, найдено Сквозное ревью UX: пройдено 12 сценариев глазами пользователя, найдено
@@ -43,7 +177,7 @@
с success-зелёным цветом, а не запутанный обратный отсчёт до завтра. с success-зелёным цветом, а не запутанный обратный отсчёт до завтра.
- **Авто-пауза на ВКС видна в Dashboard.** Раньше fires пропускались - **Авто-пауза на ВКС видна в Dashboard.** Раньше fires пропускались
молча — пользователь не понимал почему через 12 мин ничего не пришло. молча — пользователь не понимал почему через 12 мин ничего не пришло.
Сейчас info-баннер «Не дёргаем — ты на встрече» с указанием закрыть Сейчас info-баннер активной встречи с указанием закрыть
Zoom/Teams/etc. Zoom/Teams/etc.
- **Native `window.confirm()` → iOS-style ConfirmModal** в restore-операции. - **Native `window.confirm()` → iOS-style ConfirmModal** в restore-операции.
Раньше всплывал серый системный диалог. Раньше всплывал серый системный диалог.
@@ -100,7 +234,7 @@
Когда total reps за сегодня (с actualReps) ≥ dailyGoal → scheduler Когда total reps за сегодня (с actualReps) ≥ dailyGoal → scheduler
переносит fire на завтра. История = source of truth. переносит fire на завтра. История = source of truth.
- **Авто-пауза на ВКС** (#5) — сканирует процессы tasklist'ом раз в - **Авто-пауза на ВКС** (#5) — сканирует процессы tasklist'ом раз в
30с: Zoom/Teams (старый+new)/Discord/Webex/Slack/Skype/Meet/Whereby/ 30с: Zoom/Teams (старый+new)/Webex/Slack/Skype/Meet/Whereby/
GoToMeeting. Если запущен — fires не выполняются. GoToMeeting. Если запущен — fires не выполняются.
- **Адаптивный шедулер** (#2) — opt-in флаг в exercise editor. - **Адаптивный шедулер** (#2) — opt-in флаг в exercise editor.
Heuristic-модель строит hour-of-day success rate по 30 дням истории Heuristic-модель строит hour-of-day success rate по 30 дням истории
@@ -276,7 +410,7 @@
- **Modal focus trap + focus restore + aria-labelledby.** Tab/Shift-Tab - **Modal focus trap + focus restore + aria-labelledby.** Tab/Shift-Tab
больше не вываливаются на нижний слой; на закрытии фокус больше не вываливаются на нижний слой; на закрытии фокус
возвращается на триггер. возвращается на триггер.
- **Sidebar mobile drawer:** Esc закрывает, focus trap внутри, focus - **Sidebar compact drawer:** Esc закрывает, focus trap внутри, focus
restore на гамбургер, `role="dialog"` + `aria-modal`. restore на гамбургер, `role="dialog"` + `aria-modal`.
- **Tray menu i18n** — пункты меню следуют `settings.language`. - **Tray menu i18n** — пункты меню следуют `settings.language`.
- **Bilingual heatmap.** Title, легенда, weekday-лейблы и tooltip - **Bilingual heatmap.** Title, легенда, weekday-лейблы и tooltip
@@ -304,7 +438,7 @@
блокирует CSRF от browser-вкладок. Body cap 256 KB (OOM-вектор блокирует CSRF от browser-вкладок. Body cap 256 KB (OOM-вектор
закрыт). Require `application/json`. Generic 400 без error-echo. закрыт). Require `application/json`. Generic 400 без error-echo.
- **`isQuietAt` wrap-around + day filter.** С `22:00 → 07:00, - **`isQuietAt` wrap-around + day filter.** С `22:00 → 07:00,
days=[Mon..Fri]` теперь правильно проверяется день *начала* окна days=[Mon..Fri]` теперь правильно проверяется день _начала_ окна
(старт Fri 22:00 → активно ночью Sat 02:00). (старт Fri 22:00 → активно ночью Sat 02:00).
- **DST drift в `history.ts`.** Календарная арифметика (`setDate`) - **DST drift в `history.ts`.** Календарная арифметика (`setDate`)
вместо ms-арифметики — на границе DST дни больше не дублируются. вместо ms-арифметики — на границе DST дни больше не дублируются.
@@ -416,14 +550,20 @@
иконки), системный трей, автозапуск с Windows, native-уведомления, иконки), системный трей, автозапуск с Windows, native-уведомления,
NSIS-инсталлятор, auto-update через electron-updater. NSIS-инсталлятор, auto-update через electron-updater.
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.7...HEAD [Unreleased]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.6...HEAD
[0.5.7]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.7 [0.6.6]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.5...v0.6.6
[0.5.6]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.6 [0.6.5]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.4...v0.6.5
[0.5.5]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.5 [0.6.4]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.3...v0.6.4
[0.5.4]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.4 [0.6.3]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.2...v0.6.3
[0.5.3]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.3 [0.6.2]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.5.8...v0.6.2
[0.5.2]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.2 [0.5.8]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.8
[0.5.1]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.1 [0.5.7]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.7
[0.5.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.0 [0.5.6]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.6
[0.4.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.4.0 [0.5.5]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.5
[0.2.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.2.0 [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 ## 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) - **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 - **UI**: React 18 + TypeScript 5 + Tailwind 3 + framer-motion + react-router (HashRouter) + zustand 5
- **Auto-update**: electron-updater 6, generic provider, фиксированный канал - **Auto-update**: electron-updater 6, generic provider, фиксированный канал
- **Тесты**: Vitest 4 (53 теста, все зелёные) - **Тесты**: Vitest 4 (227 тестов, все зелёные)
- **Lint/format**: ESLint 8 (flat-ish .eslintrc.cjs) + Prettier 3 + EditorConfig - **Lint/format**: ESLint 8 (flat-ish .eslintrc.cjs) + Prettier 3 + EditorConfig
- **Иконки**: lucide-react (whitelisted lookup через `ICON_CHOICES`) - **Иконки**: lucide-react (whitelisted lookup через `ICON_CHOICES`)
- **Шрифты**: Plus Jakarta Sans, Bricolage Grotesque, JetBrains Mono (Google Fonts CDN) - **Шрифты**: Plus Jakarta Sans, Bricolage Grotesque, JetBrains Mono (Google Fonts CDN)
@@ -38,8 +38,14 @@
- string cap 200 chars, enum-валидация для theme/lang/notify-mode/stat - string cap 200 chars, enum-валидация для theme/lang/notify-mode/stat
- HH:MM regex для quietHours, dedup days - HH:MM regex для quietHours, dedup days
- Strip `id` из updateExercise/updateChallenge patch - 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` - **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 (КРИТИЧНО) ### Auto-update (КРИТИЧНО)
- **Фиксированный URL канала**: `…/releases/download/update-channel/latest.yml` — никогда не меняется - **Фиксированный URL канала**: `…/releases/download/update-channel/latest.yml` — никогда не меняется
- **НЕ** `…/releases/download/v${version}/…` (старая схема ломалась: установленная копия видела только свой релиз) - **НЕ** `…/releases/download/v${version}/…` (старая схема ломалась: установленная копия видела только свой релиз)
@@ -62,6 +68,20 @@
- Wrap-around (22:00 → 07:00) корректно — при wrap-active проверяется день *начала* окна - Wrap-around (22:00 → 07:00) корректно — при wrap-active проверяется день *начала* окна
- Тесты в `src/shared/quiet-hours.test.ts` - Тесты в `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-арифметика) - `src/renderer/src/lib/history.ts` — DST-safe через `shiftDays()` (calendar `setDate`, не ms-арифметика)
- Cap 10k записей, trim oldest 10% на overflow - Cap 10k записей, trim oldest 10% на overflow
@@ -100,7 +120,7 @@ npm run release -- -Bump patch
## Gitea remote ## 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` - User: `anril`
- Auth: см. `~/.claude/projects/.../memory/gitea_remote.md` - Auth: см. `~/.claude/projects/.../memory/gitea_remote.md`
- **Actions выключены** (`has_actions: false`) — релизим через PowerShell, runners не настроены - **Actions выключены** (`has_actions: false`) — релизим через PowerShell, runners не настроены
@@ -113,27 +133,44 @@ npm run release -- -Bump patch
| `package.json` | version, publish.url, scripts, deps | | `package.json` | version, publish.url, scripts, deps |
| `src/main/store.ts` | persistence, migrations, validation, atomic writes | | `src/main/store.ts` | persistence, migrations, validation, atomic writes |
| `src/main/ipc.ts` | IPC handlers с валидацией | | `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/games/dota2.ts` + `gsi-server.ts` | GSI приём матчей |
| `src/main/updater.ts` | auto-update logic, silent retries | | `src/main/updater.ts` | auto-update logic, silent retries |
| `src/shared/types.ts` | shared типы, дефолты, isQuietAt | | `src/shared/types.ts` | shared типы, дефолты, isQuietAt |
| `src/shared/ipc.ts` | IPC channel types | | `src/shared/ipc.ts` | IPC channel types |
| `src/renderer/src/i18n/dict.ts` | словари | | `src/renderer/src/i18n/dict.ts` | словари |
| `src/renderer/src/pages/Dashboard.tsx` | главная | | `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/main/validate.test.ts (78)
src/shared/quiet-hours.test.ts (7) src/renderer/src/lib/history.test.ts (31)
src/renderer/src/lib/format.test.ts (8) src/renderer/src/i18n/i18n.test.ts (15)
src/renderer/src/lib/history.test.ts (13) src/renderer/src/lib/format.test.ts (14)
src/main/games/vdf.test.ts (11) src/main/scheduler.test.ts (13) ← main: gating + приёмы пищи
src/renderer/src/i18n/i18n.test.ts (10) src/main/games/vdf.test.ts (11)
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

@@ -1,15 +1,17 @@
# Laude — Exercise Reminder # Разомнись — Exercise Reminder
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений. 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) [![release](https://img.shields.io/badge/release-v0.6.6-orange)](https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/latest)
[![tests](https://img.shields.io/badge/tests-135%20passing-green)]() [![tests](https://img.shields.io/badge/tests-245%20passing-green)]()
[![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]() [![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]()
## Что внутри ## Что внутри
- **Гибкие напоминания** — любое количество упражнений, интервал от минуты до часов, разные иконки. - **Гибкие напоминания** — любое количество упражнений, интервал от минуты до часов, разные иконки.
- **Питание** — отдельная вкладка с приёмами пищи по времени суток (завтрак/обед/ужин/перекусы), выбор дней недели, пресеты быстрого добавления. Напоминания по настенным часам, а не по интервалу.
- **История и стрики** — heatmap-календарь активности, ежедневный счётчик, серия дней подряд. - **История и стрики** — heatmap-календарь активности, ежедневный счётчик, серия дней подряд.
- **Обзор** — главный экран с ближайшим действием, планом дня, уровнем, недельными мини-челленджами и игровым долгом.
- **Тихие часы** — окно времени когда напоминания подавляются (например `22:00 → 08:00`), с выбором дней недели. - **Тихие часы** — окно времени когда напоминания подавляются (например `22:00 → 08:00`), с выбором дней недели.
- **Сделал частично** — степпер `/+` в окне напоминания: если ты сделал 5 из 10, в историю запишется честное число. - **Сделал частично** — степпер `/+` в окне напоминания: если ты сделал 5 из 10, в историю запишется честное число.
- **Игровая интеграция (Dota 2)** — Game State Integration читает статистику матча, после Победа/Поражение показывает экран с «причитающимися» повторениями (например `10 смертей × 3 = 30 приседаний`). - **Игровая интеграция (Dota 2)** — Game State Integration читает статистику матча, после Победа/Поражение показывает экран с «причитающимися» повторениями (например `10 смертей × 3 = 30 приседаний`).
@@ -34,7 +36,7 @@ Windows SmartScreen может предупредить «не доверено
## Разработка ## Разработка
```bash ```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 cd laude
npm install npm install
npm run dev npm run dev
@@ -44,8 +46,10 @@ npm run dev
```bash ```bash
npm run typecheck # tsc по main + renderer npm run typecheck # tsc по main + renderer
npm run verify # typecheck + tests + lint + build + audit summary
npm run test # vitest в watch-режиме npm run test # vitest в watch-режиме
npm run test:run # vitest один раз (для CI) npm run test:run # vitest один раз (для CI)
npm run dev:renderer # renderer-стенд с демо-данными для UI-проверки
npm run build # сборка без NSIS npm run build # сборка без NSIS
npm run dist # сборка + NSIS-инсталлятор → release/ npm run dist # сборка + NSIS-инсталлятор → release/
npm run release -- -Bump patch # bump версии + tag + push + upload в Gitea npm run release -- -Bump patch # bump версии + tag + push + upload в Gitea
@@ -55,8 +59,8 @@ npm run release -- -Bump patch # bump версии + tag + push + upload в G
## Архитектура ## Архитектура
- **Electron 33** — multi-process: main (Node/scheduler/GSI) + preload (contextBridge) + renderer (React) - **Electron 42** — multi-process: main (Node/scheduler/GSI) + preload (contextBridge) + renderer (React)
- **Renderer** — React 18, TypeScript 5, Vite 5, Tailwind 3, framer-motion, react-router, zustand - **Renderer** — React 18, TypeScript 5, Vite 7, Tailwind 3, framer-motion, react-router, zustand
- **Persistence** — единственный JSON-файл `%APPDATA%\Exercise Reminder\app-state.json` (debounced writes) - **Persistence** — единственный JSON-файл `%APPDATA%\Exercise Reminder\app-state.json` (debounced writes)
- **IPC** — типизированные каналы через `src/shared/ipc.ts`, обёрнуто preload-ом - **IPC** — типизированные каналы через `src/shared/ipc.ts`, обёрнуто preload-ом
- **i18n** — самописная микро-система: `src/renderer/src/i18n/dict.ts` (плоский словарь ~200 ключей × 2 языка) + хук `useT()` - **i18n** — самописная микро-система: `src/renderer/src/i18n/dict.ts` (плоский словарь ~200 ключей × 2 языка) + хук `useT()`
@@ -66,21 +70,36 @@ npm run release -- -Bump patch # bump версии + tag + push + upload в G
## Тесты ## Тесты
``` ```
src/shared/types.test.ts (4) src/main/validate.test.ts (78)
src/shared/quiet-hours.test.ts (5) src/renderer/src/lib/history.test.ts (31)
src/renderer/src/lib/format.test.ts (8) src/renderer/src/i18n/i18n.test.ts (15)
src/renderer/src/lib/history.test.ts (13) src/renderer/src/lib/format.test.ts (14)
src/main/games/vdf.test.ts (11) src/main/scheduler.test.ts (14)
src/renderer/src/i18n/i18n.test.ts (10) src/main/games/vdf.test.ts (11)
───────────────────────────────────────── src/main/store.test.ts (12)
51 ✓ 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 (8)
src/shared/quiet-hours.test.ts (7)
src/main/adaptive.test.ts (6)
src/renderer/src/lib/day-plan.test.ts (6)
src/shared/types.test.ts (4)
src/renderer/src/lib/icon-choices.test.ts (4)
src/renderer/src/lib/momentum.test.ts (3)
src/main/autostart.test.ts (3)
──────────────────────────────────────────
245 ✓
``` ```
Покрытие: чистые 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 ## Stack

View File

@@ -40,7 +40,7 @@ latest.yml # манифест: версия +
В `package.json``build.publish.url`: В `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 **никогда не меняется**. Все версии (и сегодняшние, и будущие) Этот URL **никогда не меняется**. Все версии (и сегодняшние, и будущие)

88
docs/SECURITY_TRIAGE.md Normal file
View File

@@ -0,0 +1,88 @@
# Security triage
Дата последнего triage: 2026-06-06
Этот файл фиксирует текущий статус `npm audit`, принятые решения по
dependency-upgrade и минимальные правила перед релизом. Он не заменяет
регулярное обновление зависимостей: цель в том, чтобы не терять контекст между
релизами и отличать runtime-риск от build-chain риска.
## Текущий статус
`npm run verify` выполняет:
- typecheck;
- tests;
- lint;
- build;
- audit summary.
Наблюдаемый audit snapshot после upgrade:
- 0 vulnerabilities;
- `npm run verify` проходит полностью;
- `npm run dist:dir` собирает unpacked Windows build.
## Выполненные обновления
- `electron` обновлен до `42.3.3`;
- `electron-builder` обновлен до `26.15.0`;
- `electron-vite` обновлен до `5.0.0`;
- `vite` обновлен до `7.3.5`;
- `@vitejs/plugin-react` обновлен до `5.2.0`;
- `electron-updater` обновлен до `6.8.9`;
- `esbuild` обновлен до `0.28.0`.
`vite` оставлен на major 7, а не поднят до 8, потому что актуальный
`electron-vite@5.0.0` объявляет peer range `^5.0.0 || ^6.0.0 || ^7.0.0`.
Переход на Vite 8 стоит делать отдельным шагом после совместимого релиза
`electron-vite`.
## Runtime baseline
Electron является runtime-платформой приложения, поэтому его audit-риск был
самым важным. После обновления runtime-риск из audit закрыт, но перед релизом
все равно нужен ручной smoke test Electron-поведения.
Текущие mitigations:
- `sandbox: true`;
- `contextIsolation: true`;
- `nodeIntegration: false`;
- строгий preload API через `contextBridge`;
- IPC runtime validation;
- CSP в renderer `index.html`;
- allowlist для внешних URL;
- deny-by-default permission handlers;
- запрет `webview` attach;
- renderer error/unhandled rejection reporting в main logs.
## Build-chain baseline
`electron-builder`, `electron-vite`, `vite` и `esbuild` обновлены отдельным
шагом от runtime upgrade. `npm run dist:dir` подтверждает, что packaged build
создается после обновления build-chain.
Перед публикацией installer все равно нужно проверить:
- NSIS installer build через `npm run dist`;
- запуск установленного приложения;
- сохранение данных в `%APPDATA%\Exercise Reminder\`;
- auto-update metadata и `update-channel`;
- импорт/экспорт данных;
- tray behavior;
- main window и reminder window;
- Dota 2 GSI install/uninstall и callback listener.
## Release rule
Перед релизом:
- `npm run verify`;
- `npm run dist`;
- smoke test installed app;
- проверка `update-channel`;
- повторный `npm audit`.
Если новый audit снова сообщает high runtime issue, релиз нужно считать
blocked, пока риск не разобран отдельно.

5102
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,13 @@
{ {
"name": "laude", "name": "laude",
"version": "0.5.7", "version": "0.6.6",
"description": "Exercise reminder — Windows desktop app", "description": "Разомнись — Windows desktop break and exercise reminder",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "AnRil", "author": "AnRil",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "electron-vite dev", "dev": "electron-vite dev",
"dev:renderer": "vite --config vite.renderer.config.mjs",
"build": "electron-vite build", "build": "electron-vite build",
"preview": "electron-vite preview", "preview": "electron-vite preview",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json", "typecheck:node": "tsc --noEmit -p tsconfig.node.json",
@@ -18,6 +19,7 @@
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"*.{json,md}\" \".github/**/*.yml\"", "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"*.{json,md}\" \".github/**/*.yml\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{json,md}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{json,md}\"",
"lint": "eslint src --ext .ts,.tsx --max-warnings 0", "lint": "eslint src --ext .ts,.tsx --max-warnings 0",
"verify": "powershell -ExecutionPolicy Bypass -File scripts/verify.ps1",
"dist": "electron-vite build && electron-builder --win --x64", "dist": "electron-vite build && electron-builder --win --x64",
"dist:dir": "electron-vite build && electron-builder --win --x64 --dir", "dist:dir": "electron-vite build && electron-builder --win --x64 --dir",
"publish": "electron-vite build && electron-builder --win --x64 --publish always", "publish": "electron-vite build && electron-builder --win --x64 --publish always",
@@ -28,7 +30,7 @@
"@fontsource/bricolage-grotesque": "^5.2.10", "@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/plus-jakarta-sans": "^5.2.8", "@fontsource/plus-jakarta-sans": "^5.2.8",
"electron-updater": "^6.8.3", "electron-updater": "^6.8.9",
"framer-motion": "^11.11.17", "framer-motion": "^11.11.17",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -42,11 +44,13 @@
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^4.1.6",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"electron": "^33.2.0", "electron": "^42.3.3",
"electron-builder": "^25.1.8", "electron-builder": "^26.15.0",
"electron-vite": "^2.3.0", "electron-vite": "^5.0.0",
"esbuild": "^0.28.0",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
@@ -54,7 +58,7 @@
"prettier": "^3.4.1", "prettier": "^3.4.1",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.15",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.11", "vite": "^7.3.5",
"vitest": "^4.1.6" "vitest": "^4.1.6"
}, },
"build": { "build": {
@@ -101,7 +105,7 @@
}, },
"publish": { "publish": {
"provider": "generic", "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" "channel": "latest"
} }
} }

View File

@@ -51,7 +51,7 @@ $ErrorActionPreference = 'Stop'
$repoOwner = 'AnRil' $repoOwner = 'AnRil'
$repoName = 'laude' $repoName = 'laude'
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git' $giteaHost = 'git.xn--90adajar8af4h.xn--p1ai'
$channelTag = 'update-channel' $channelTag = 'update-channel'
# --- Pre-flight ---------------------------------------------------------- # --- Pre-flight ----------------------------------------------------------
@@ -116,9 +116,6 @@ $pkgJson = [System.IO.File]::ReadAllText($pkgPath, $utf8NoBom)
$pkgJson = $pkgJson -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`"" $pkgJson = $pkgJson -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`""
[System.IO.File]::WriteAllText($pkgPath, $pkgJson, $utf8NoBom) [System.IO.File]::WriteAllText($pkgPath, $pkgJson, $utf8NoBom)
git add package.json
git commit -m "chore(release): $tag"
# --- Quality gates ------------------------------------------------------ # --- Quality gates ------------------------------------------------------
if (-not $SkipBuild) { if (-not $SkipBuild) {
Write-Host "Typecheck..." -ForegroundColor Cyan Write-Host "Typecheck..." -ForegroundColor Cyan
@@ -145,6 +142,12 @@ foreach ($f in @($installer, $blockmap, $manifest)) {
} }
} }
# Commit only after quality gates and artifact verification pass. If a check
# fails, package.json remains modified but history does not get a broken
# release commit/tag.
git add package.json
git commit -m "chore(release): $tag"
# --- Tag + push --------------------------------------------------------- # --- Tag + push ---------------------------------------------------------
Write-Host "Tagging $tag and pushing..." -ForegroundColor Cyan Write-Host "Tagging $tag and pushing..." -ForegroundColor Cyan
git tag -a $tag -m "Release $tag" git tag -a $tag -m "Release $tag"

View File

@@ -30,9 +30,13 @@ param(
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
# Windows PowerShell 5.1 can default to older TLS settings and fail against
# modern HTTPS endpoints before curl.exe gets a chance to upload assets.
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$repoOwner = 'AnRil' $repoOwner = 'AnRil'
$repoName = 'laude' $repoName = 'laude'
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git' $giteaHost = 'git.xn--90adajar8af4h.xn--p1ai'
$apiBase = "https://$giteaHost/api/v1" $apiBase = "https://$giteaHost/api/v1"
if (-not $env:GITEA_TOKEN) { if (-not $env:GITEA_TOKEN) {

31
scripts/verify.ps1 Normal file
View File

@@ -0,0 +1,31 @@
$ErrorActionPreference = 'Stop'
function Invoke-Step {
param(
[Parameter(Mandatory = $true)][string]$Name,
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string[]]$Arguments
)
Write-Host ""
Write-Host "==> $Name"
& $Command @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Name failed with exit code $LASTEXITCODE"
}
}
Invoke-Step 'Typecheck' 'npm.cmd' @('run', 'typecheck')
Invoke-Step 'Tests' 'npm.cmd' @('run', 'test:run')
Invoke-Step 'Lint' 'npm.cmd' @('run', 'lint')
Invoke-Step 'Build' 'npm.cmd' @('run', 'build')
Write-Host ""
Write-Host "==> Audit summary"
& npm.cmd audit --audit-level=moderate
if ($LASTEXITCODE -ne 0) {
Write-Warning 'npm audit reported vulnerabilities. See the output above; verification gates still passed.'
}
Write-Host ""
Write-Host "Verification complete."

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

@@ -0,0 +1,73 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
const h = vi.hoisted(() => ({
app: {
setLoginItemSettings: vi.fn(),
getLoginItemSettings: vi.fn(() => ({ openAtLogin: false })),
wasOpenedAsHidden: false
}
}))
vi.mock('electron', () => ({ app: h.app }))
const originalPlatform = process.platform
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true
})
}
async function load(): Promise<typeof import('./autostart')> {
vi.resetModules()
return import('./autostart')
}
beforeEach(() => {
setPlatform('win32')
h.app.setLoginItemSettings.mockClear()
h.app.getLoginItemSettings.mockReset()
h.app.getLoginItemSettings.mockReturnValue({ openAtLogin: false })
})
afterEach(() => {
setPlatform(originalPlatform)
})
describe('autostart', () => {
it('writes Windows login item with the hidden startup argument', async () => {
const { setAutostart } = await load()
setAutostart(true)
expect(h.app.setLoginItemSettings).toHaveBeenCalledWith({
openAtLogin: true,
openAsHidden: true,
path: process.execPath,
args: ['--hidden']
})
})
it('reads Windows login item using the same path and args', async () => {
h.app.getLoginItemSettings.mockReturnValue({ openAtLogin: true })
const { isAutostartEnabled } = await load()
expect(isAutostartEnabled()).toBe(true)
expect(h.app.getLoginItemSettings).toHaveBeenCalledWith({
path: process.execPath,
args: ['--hidden']
})
})
it('does nothing on non-Windows platforms', async () => {
setPlatform('linux')
const { setAutostart, isAutostartEnabled } = await load()
setAutostart(true)
expect(isAutostartEnabled()).toBe(false)
expect(h.app.setLoginItemSettings).not.toHaveBeenCalled()
expect(h.app.getLoginItemSettings).not.toHaveBeenCalled()
})
})

View File

@@ -1,19 +1,29 @@
import { app } from 'electron' import { app } from 'electron'
const HIDDEN_FLAG = '--hidden' const HIDDEN_FLAG = '--hidden'
type LoginItemOptions = NonNullable<
Parameters<typeof app.getLoginItemSettings>[0]
>
function loginItemOptions(): LoginItemOptions {
return {
path: process.execPath,
args: [HIDDEN_FLAG]
}
}
export function setAutostart(enabled: boolean): void { export function setAutostart(enabled: boolean): void {
if (process.platform !== 'win32') return if (process.platform !== 'win32') return
app.setLoginItemSettings({ app.setLoginItemSettings({
...loginItemOptions(),
openAtLogin: enabled, openAtLogin: enabled,
path: process.execPath, openAsHidden: true
args: [HIDDEN_FLAG]
}) })
} }
export function isAutostartEnabled(): boolean { export function isAutostartEnabled(): boolean {
if (process.platform !== 'win32') return false if (process.platform !== 'win32') return false
return app.getLoginItemSettings().openAtLogin return app.getLoginItemSettings(loginItemOptions()).openAtLogin
} }
export function wasStartedHidden(): boolean { export function wasStartedHidden(): boolean {

79
src/main/diagnostics.ts Normal file
View File

@@ -0,0 +1,79 @@
import { app, clipboard, shell } from 'electron'
import { existsSync, statSync } from 'node:fs'
import type { DiagnosticsInfo } from '@shared/types'
import {
getGsiBaseUrl,
getGsiPort,
isGsiServerRunning
} from './games/gsi-server'
import { listGamesStatus } from './games/registry'
import { getLogDir } from './logger'
import { isMeetingActiveSync } from './meeting-detect'
import { getState, getStorePath } from './store'
import { getUpdaterStatus } from './updater'
function fileSize(path: string): number | null {
try {
if (!path || !existsSync(path)) return null
return statSync(path).size
} catch {
return null
}
}
export async function getDiagnosticsInfo(): Promise<DiagnosticsInfo> {
const state = getState()
const store = getStorePath()
const games = await listGamesStatus()
return {
generatedAt: Date.now(),
app: {
version: app.getVersion(),
isPackaged: app.isPackaged,
platform: process.platform,
arch: process.arch
},
runtime: {
electron: process.versions.electron ?? 'unknown',
chrome: process.versions.chrome ?? 'unknown',
node: process.versions.node
},
paths: {
userData: app.getPath('userData'),
store,
logs: getLogDir()
},
store: {
bytes: fileSize(store),
exercises: state.exercises.length,
meals: state.meals.length,
challenges: state.challenges.length,
history: state.history?.length ?? 0
},
updater: getUpdaterStatus(),
games,
gsi: {
running: isGsiServerRunning(),
port: getGsiPort(),
baseUrl: getGsiBaseUrl()
},
meetingActive: isMeetingActiveSync()
}
}
export async function openLogsFolder(): Promise<{
ok: boolean
error?: string
}> {
const dir = getLogDir()
if (!dir) return { ok: false, error: 'logs-unavailable' }
const error = await shell.openPath(dir)
return error ? { ok: false, error } : { ok: true }
}
export async function copyDiagnosticsToClipboard(): Promise<DiagnosticsInfo> {
const info = await getDiagnosticsInfo()
clipboard.writeText(JSON.stringify(info, null, 2))
return info
}

View File

@@ -144,3 +144,7 @@ export function getGsiBaseUrl(): string {
export function getGsiPort(): number { export function getGsiPort(): number {
return PORT return PORT
} }
export function isGsiServerRunning(): boolean {
return server !== null
}

View File

@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const h = vi.hoisted(() => ({
provider: {
displayName: 'Dota 2',
start: vi.fn(),
stop: vi.fn(),
detect: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
reconcile: vi.fn()
},
startGsiServer: vi.fn(),
stopGsiServer: vi.fn(),
onLaunchOptionsApplied: vi.fn(),
gamesEnabled: { dota2: true },
fireMatchSummary: vi.fn(),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
}))
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] }
}))
vi.mock('./dota2', () => ({
Dota2Provider: vi.fn(function Dota2Provider() {
return h.provider
})
}))
vi.mock('./gsi-server', () => ({
startGsiServer: h.startGsiServer,
stopGsiServer: h.stopGsiServer
}))
vi.mock('./steam-launch-options', () => ({
onLaunchOptionsApplied: h.onLaunchOptionsApplied
}))
vi.mock('../store', () => ({
getChallenges: () => [],
getGamesEnabled: () => h.gamesEnabled
}))
vi.mock('../notifications', () => ({
fireMatchSummary: h.fireMatchSummary
}))
vi.mock('../logger', () => ({
log: h.log
}))
async function loadRegistry(): Promise<typeof import('./registry')> {
return import('./registry')
}
beforeEach(() => {
vi.resetModules()
h.provider.start.mockResolvedValue(undefined)
h.provider.stop.mockResolvedValue(undefined)
h.provider.detect.mockResolvedValue({
id: 'dota2',
name: 'Dota 2',
installed: true,
integrationActive: true,
launchOptionStatus: 'applied',
enabled: true
})
h.provider.install.mockResolvedValue(undefined)
h.provider.uninstall.mockResolvedValue(undefined)
h.provider.reconcile.mockResolvedValue(undefined)
h.startGsiServer.mockReset()
h.startGsiServer.mockResolvedValue(undefined)
h.stopGsiServer.mockReset()
h.stopGsiServer.mockResolvedValue(undefined)
h.onLaunchOptionsApplied.mockClear()
h.fireMatchSummary.mockClear()
h.log.info.mockClear()
h.log.warn.mockClear()
h.log.error.mockClear()
h.log.debug.mockClear()
})
describe('games registry lifecycle', () => {
it('сбрасывает running после ошибки старта GSI и позволяет повторный старт', async () => {
h.startGsiServer
.mockRejectedValueOnce(new Error('port busy'))
.mockResolvedValueOnce(undefined)
const { startGamesRegistry } = await loadRegistry()
await startGamesRegistry()
await startGamesRegistry()
expect(h.startGsiServer).toHaveBeenCalledTimes(2)
expect(h.provider.start).toHaveBeenCalledTimes(1)
})
it('stopGamesRegistry ждёт полного shutdown GSI-сервера', async () => {
let releaseStop!: () => void
const stopPromise = new Promise<void>((resolve) => {
releaseStop = resolve
})
h.stopGsiServer.mockReturnValue(stopPromise)
const { startGamesRegistry, stopGamesRegistry } = await loadRegistry()
await startGamesRegistry()
let resolved = false
const pending = stopGamesRegistry().then(() => {
resolved = true
})
await Promise.resolve()
expect(resolved).toBe(false)
releaseStop()
await pending
expect(resolved).toBe(true)
})
})

View File

@@ -87,6 +87,7 @@ export async function startGamesRegistry(): Promise<void> {
await startGsiServer() await startGsiServer()
log.info('[games] GSI server started on port 4701') log.info('[games] GSI server started on port 4701')
} catch (err) { } catch (err) {
running = false
log.error('[games] GSI server failed to start', err) log.error('[games] GSI server failed to start', err)
return return
} }
@@ -119,7 +120,7 @@ export async function stopGamesRegistry(): Promise<void> {
for (const id of Object.keys(providers) as GameId[]) { for (const id of Object.keys(providers) as GameId[]) {
await providers[id].stop() await providers[id].stop()
} }
stopGsiServer() await stopGsiServer()
} }
export async function listGamesStatus(): Promise<GameStatus[]> { export async function listGamesStatus(): Promise<GameStatus[]> {

View File

@@ -13,9 +13,27 @@ import { broadcastState } from './state-actions'
import { startGamesRegistry, stopGamesRegistry } from './games/registry' import { startGamesRegistry, stopGamesRegistry } from './games/registry'
import { initUpdater, stopUpdater } from './updater' import { initUpdater, stopUpdater } from './updater'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import { log } from './logger'
import { installSecurityHardening } from './security'
const APP_ID = 'com.anril.exercise-reminder' 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 // Must be set BEFORE app.whenReady() for Windows toasts to show
// the correct app name / icon in Action Center. // the correct app name / icon in Action Center.
app.setAppUserModelId(APP_ID) app.setAppUserModelId(APP_ID)
@@ -28,6 +46,7 @@ if (!gotLock) {
app.on('second-instance', () => showMainWindow()) app.on('second-instance', () => showMainWindow())
app.whenReady().then(() => { app.whenReady().then(() => {
installSecurityHardening()
registerIpc() registerIpc()
createTray() createTray()
@@ -38,7 +57,7 @@ if (!gotLock) {
startScheduler() startScheduler()
startGamesRegistry().catch((err) => startGamesRegistry().catch((err) =>
console.error('games registry failed:', err) log.error('[index] games registry failed', err)
) )
initUpdater() initUpdater()
@@ -88,7 +107,7 @@ if (!gotLock) {
try { try {
await stopGamesRegistry() await stopGamesRegistry()
} catch (err) { } catch (err) {
console.error('[index] stopGamesRegistry threw:', err) log.error('[index] stopGamesRegistry threw', err)
} }
flushNow() flushNow()
app.exit(0) app.exit(0)

View File

@@ -7,15 +7,23 @@ import {
dialog, dialog,
shell shell
} from 'electron' } from 'electron'
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
import { readFileSync, writeFileSync } from 'node:fs' import { readFileSync, writeFileSync } from 'node:fs'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import type { Exercise, GameId, Settings } from '@shared/types' import type {
Exercise,
GameId,
RendererErrorReport,
Settings
} from '@shared/types'
import { import {
addChallenge, addChallenge,
addExercise, addExercise,
addMeal,
clearHistory, clearHistory,
deleteChallenge, deleteChallenge,
deleteExercise, deleteExercise,
deleteMeal,
exportState, exportState,
getHistory, getHistory,
getState, getState,
@@ -23,14 +31,16 @@ import {
importState, importState,
markChallengeDone, markChallengeDone,
markDone, markDone,
markMealDone,
setGameEnabled, setGameEnabled,
skip, skip,
snooze, snooze,
updateChallenge, updateChallenge,
updateExercise, updateExercise,
updateMeal,
updateSettings updateSettings
} from './store' } from './store'
import { broadcastState } from './state-actions' import { broadcastHistoryChanged, broadcastState } from './state-actions'
import { setAutostart, isAutostartEnabled } from './autostart' import { setAutostart, isAutostartEnabled } from './autostart'
import { forceCheck } from './scheduler' import { forceCheck } from './scheduler'
import { hideReminderWindow, getMainWindow } from './windows' import { hideReminderWindow, getMainWindow } from './windows'
@@ -57,12 +67,81 @@ import {
validateExerciseInput, validateExerciseInput,
validateExercisePatch, validateExercisePatch,
validateId, validateId,
validateMealInput,
validateMealPatch,
validateSettingsPatch, validateSettingsPatch,
validateSnoozeMinutes validateSnoozeMinutes
} from './validate' } from './validate'
import { log } from './logger'
import {
copyDiagnosticsToClipboard,
getDiagnosticsInfo,
openLogsFolder
} from './diagnostics'
const MAX_RENDERER_ERROR_FIELD = 8_000
/**
* Враппер вокруг `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)
}
})
}
function cleanRendererError(raw: unknown): RendererErrorReport | null {
if (typeof raw !== 'object' || raw === null) return null
const r = raw as Record<string, unknown>
const message = typeof r.message === 'string' ? r.message.trim() : ''
if (!message) return null
const clean = (v: unknown): string | undefined =>
typeof v === 'string' ? v.slice(0, MAX_RENDERER_ERROR_FIELD) : undefined
return {
message: message.slice(0, MAX_RENDERER_ERROR_FIELD),
stack: clean(r.stack),
componentStack: clean(r.componentStack),
source: clean(r.source)
}
}
export function registerIpc(): void { export function registerIpc(): void {
ipcMain.handle(IPC.getState, () => { safeHandle(IPC.getState, () => {
// Без history (см. getStateForRenderer) и с актуальным значением // Без history (см. getStateForRenderer) и с актуальным значением
// autostart из OS — мутацию делаем по копии, не по cache. // autostart из OS — мутацию делаем по копии, не по cache.
const state = getStateForRenderer() const state = getStateForRenderer()
@@ -73,7 +152,7 @@ export function registerIpc(): void {
return state return state
}) })
ipcMain.handle(IPC.addExercise, (_e, input: unknown) => { safeHandle(IPC.addExercise, (_e, input: unknown) => {
const safe = validateExerciseInput(input) const safe = validateExerciseInput(input)
if (!safe) return null if (!safe) return null
const ex = addExercise(safe) const ex = addExercise(safe)
@@ -81,19 +160,16 @@ export function registerIpc(): void {
return ex return ex
}) })
ipcMain.handle( safeHandle(IPC.updateExercise, (_e, idRaw: unknown, patchRaw: unknown) => {
IPC.updateExercise, const id = validateId(idRaw)
(_e, idRaw: unknown, patchRaw: unknown) => { const patch = validateExercisePatch(patchRaw)
const id = validateId(idRaw) if (!id || !patch) return null
const patch = validateExercisePatch(patchRaw) const ex = updateExercise(id, patch)
if (!id || !patch) return null broadcastState()
const ex = updateExercise(id, patch) return ex
broadcastState() })
return ex
}
)
ipcMain.handle(IPC.deleteExercise, (_e, idRaw: unknown) => { safeHandle(IPC.deleteExercise, (_e, idRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
if (!id) return false if (!id) return false
const ok = deleteExercise(id) const ok = deleteExercise(id)
@@ -101,48 +177,99 @@ export function registerIpc(): void {
return ok return ok
}) })
ipcMain.handle( safeHandle(IPC.toggleExercise, (_e, idRaw: unknown, enabledRaw: unknown) => {
IPC.toggleExercise,
(_e, idRaw: unknown, enabledRaw: unknown) => {
const id = validateId(idRaw)
if (!id || typeof enabledRaw !== 'boolean') return null
const patch: Partial<Exercise> = { enabled: enabledRaw }
if (enabledRaw) {
const ex = getState().exercises.find((e) => e.id === id)
if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
}
const ex = updateExercise(id, patch)
broadcastState()
return ex
}
)
ipcMain.handle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
if (!id) return null if (!id || typeof enabledRaw !== 'boolean') return null
const ex = markDone(id, validateActualReps(repsRaw)) const patch: Partial<Exercise> = { enabled: enabledRaw }
if (enabledRaw) {
const ex = getState().exercises.find((e) => e.id === id)
if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
}
const ex = updateExercise(id, patch)
broadcastState() broadcastState()
return ex return ex
}) })
ipcMain.handle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => { safeHandle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => {
const id = validateId(idRaw)
if (!id) return null
const ex = markDone(id, validateActualReps(repsRaw))
if (ex) {
broadcastState()
broadcastHistoryChanged()
}
return ex
})
safeHandle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
const minutes = validateSnoozeMinutes(minRaw) const minutes = validateSnoozeMinutes(minRaw)
if (!id || minutes === null) return null if (!id || minutes === null) return null
const ex = snooze(id, minutes) const ex = snooze(id, minutes)
broadcastState() if (ex) {
broadcastState()
broadcastHistoryChanged()
}
return ex return ex
}) })
ipcMain.handle(IPC.skip, (_e, idRaw: unknown) => { safeHandle(IPC.skip, (_e, idRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
if (!id) return null if (!id) return null
const ex = skip(id) const ex = skip(id)
broadcastState() if (ex) {
broadcastState()
broadcastHistoryChanged()
}
return ex 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)
if (m) {
broadcastState()
broadcastHistoryChanged()
}
return m
})
safeHandle(IPC.updateSettings, (_e, patchRaw: unknown) => {
const patch = validateSettingsPatch(patchRaw) const patch = validateSettingsPatch(patchRaw)
if (!patch) return null if (!patch) return null
if (patch.startWithWindows !== undefined) { if (patch.startWithWindows !== undefined) {
@@ -162,19 +289,19 @@ export function registerIpc(): void {
return settings return settings
}) })
ipcMain.handle(IPC.pauseAll, () => { safeHandle(IPC.pauseAll, () => {
updateSettings({ globalEnabled: false }) updateSettings({ globalEnabled: false })
broadcastState() broadcastState()
refreshMenu() refreshMenu()
}) })
ipcMain.handle(IPC.resumeAll, () => { safeHandle(IPC.resumeAll, () => {
updateSettings({ globalEnabled: true }) updateSettings({ globalEnabled: true })
broadcastState() broadcastState()
forceCheck() forceCheck()
refreshMenu() refreshMenu()
}) })
ipcMain.handle(IPC.getAccentColor, () => { safeHandle(IPC.getAccentColor, () => {
try { try {
return '#' + systemPreferences.getAccentColor() return '#' + systemPreferences.getAccentColor()
} catch { } catch {
@@ -182,45 +309,58 @@ export function registerIpc(): void {
} }
}) })
ipcMain.handle(IPC.getOsTheme, () => safeHandle(IPC.getOsTheme, () =>
nativeTheme.shouldUseDarkColors ? 'dark' : 'light' 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()) safeHandle(IPC.getDiagnostics, () => getDiagnosticsInfo())
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
ipcMain.on(IPC.minimizeMain, (event) => { safeHandle(IPC.openLogsFolder, () => openLogsFolder())
safeHandle(IPC.copyDiagnostics, () => copyDiagnosticsToClipboard())
safeHandle(IPC.reportRendererError, (_e, raw: unknown) => {
const report = cleanRendererError(raw)
if (!report) return false
log.error('[renderer] error reported', report)
return true
})
safeHandle(IPC.quit, () => app.quit())
safeHandle(IPC.reminderClose, () => hideReminderWindow())
safeOn(IPC.minimizeMain, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize() BrowserWindow.fromWebContents(event.sender)?.minimize()
}) })
ipcMain.on(IPC.toggleMaximizeMain, (event) => { safeOn(IPC.toggleMaximizeMain, (event) => {
const win = BrowserWindow.fromWebContents(event.sender) const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return if (!win) return
if (win.isMaximized()) win.unmaximize() if (win.isMaximized()) win.unmaximize()
else win.maximize() else win.maximize()
}) })
ipcMain.handle(IPC.isMaximizedMain, (event) => { safeHandle(IPC.isMaximizedMain, (event) => {
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false
}) })
ipcMain.on(IPC.closeMain, () => { safeOn(IPC.closeMain, () => {
const main = getMainWindow() const main = getMainWindow()
if (!main) return if (!main) return
if (getState().settings.minimizeToTray) main.hide() if (getState().settings.minimizeToTray) main.hide()
else main.close() else main.close()
}) })
ipcMain.on(IPC.hideMain, () => getMainWindow()?.hide()) safeOn(IPC.hideMain, () => getMainWindow()?.hide())
// Games // 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) const status = await installGame(id)
setGameEnabled(id, true) setGameEnabled(id, true)
await toggleGame(id, true) await toggleGame(id, true)
@@ -230,7 +370,7 @@ export function registerIpc(): void {
return status return status
}) })
ipcMain.handle(IPC.gameUninstall, async (_e, id: GameId) => { safeHandle(IPC.gameUninstall, async (_e, id: GameId) => {
const status = await uninstallGame(id) const status = await uninstallGame(id)
setGameEnabled(id, false) setGameEnabled(id, false)
const all = await listGamesStatus() const all = await listGamesStatus()
@@ -239,7 +379,7 @@ export function registerIpc(): void {
return status return status
}) })
ipcMain.handle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => { safeHandle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
setGameEnabled(id, enabled) setGameEnabled(id, enabled)
await toggleGame(id, enabled) await toggleGame(id, enabled)
const all = await listGamesStatus() const all = await listGamesStatus()
@@ -247,67 +387,59 @@ export function registerIpc(): void {
broadcastState() broadcastState()
}) })
ipcMain.handle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => { safeHandle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
// Opens Steam's library; user manually adds launch options. // Opens Steam's library; user manually adds launch options.
shell.openExternal('steam://nav/games/details/570') shell.openExternal('steam://nav/games/details/570')
}) })
// Challenges // Challenges
ipcMain.handle(IPC.addChallenge, (_e, input: unknown) => { safeHandle(IPC.addChallenge, (_e, input: unknown) => {
const safe = validateChallengeInput(input) const safe = validateChallengeInput(input)
if (!safe) return null if (!safe) return null
const c = addChallenge(safe) const c = addChallenge(safe)
broadcastState() broadcastState()
return c return c
}) })
ipcMain.handle( safeHandle(IPC.updateChallenge, (_e, idRaw: unknown, patchRaw: unknown) => {
IPC.updateChallenge, const id = validateId(idRaw)
(_e, idRaw: unknown, patchRaw: unknown) => { const patch = validateChallengePatch(patchRaw)
const id = validateId(idRaw) if (!id || !patch) return null
const patch = validateChallengePatch(patchRaw) const c = updateChallenge(id, patch)
if (!id || !patch) return null broadcastState()
const c = updateChallenge(id, patch) return c
broadcastState() })
return c safeHandle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
}
)
ipcMain.handle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
if (!id) return false if (!id) return false
const ok = deleteChallenge(id) const ok = deleteChallenge(id)
broadcastState() broadcastState()
return ok return ok
}) })
ipcMain.handle( safeHandle(IPC.toggleChallenge, (_e, idRaw: unknown, enabledRaw: unknown) => {
IPC.toggleChallenge, const id = validateId(idRaw)
(_e, idRaw: unknown, enabledRaw: unknown) => { if (!id || typeof enabledRaw !== 'boolean') return null
const id = validateId(idRaw) const c = updateChallenge(id, { enabled: enabledRaw })
if (!id || typeof enabledRaw !== 'boolean') return null broadcastState()
const c = updateChallenge(id, { enabled: enabledRaw }) return c
broadcastState() })
return c
}
)
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow()) safeHandle(IPC.closeMatchSummary, () => hideReminderWindow())
ipcMain.handle( safeHandle(IPC.markChallengeDone, (_e, idRaw: unknown, repsRaw: unknown) => {
IPC.markChallengeDone, const id = validateId(idRaw)
(_e, idRaw: unknown, repsRaw: unknown) => { const reps = validateActualReps(repsRaw)
const id = validateId(idRaw) if (!id || reps === undefined || reps <= 0) return false
const reps = validateActualReps(repsRaw) markChallengeDone(id, reps)
if (!id || reps === undefined || reps <= 0) return false broadcastState()
markChallengeDone(id, reps) broadcastHistoryChanged()
broadcastState() return true
return true })
}
)
// Dev helper: simulate a match end with given stats. NEVER registered in // Dev helper: simulate a match end with given stats. NEVER registered in
// packaged builds — a compromised renderer (XSS, malicious npm dep) could // packaged builds — a compromised renderer (XSS, malicious npm dep) could
// otherwise fabricate arbitrary match-end events at will. // otherwise fabricate arbitrary match-end events at will.
if (!app.isPackaged) { if (!app.isPackaged) {
ipcMain.handle( safeHandle(
IPC.devSimulateMatchEnd, IPC.devSimulateMatchEnd,
(_e, id: GameId, stats: Record<string, number>) => { (_e, id: GameId, stats: Record<string, number>) => {
simulateMatchEnd(id, stats) simulateMatchEnd(id, stats)
@@ -316,62 +448,75 @@ export function registerIpc(): void {
} }
// Auto-updater // Auto-updater
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus()) safeHandle(IPC.updaterStatus, () => getUpdaterStatus())
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates()) safeHandle(IPC.updaterCheck, () => checkForUpdates())
// download/install — fire-and-forget. Прогресс и завершение приходят в // download/install — fire-and-forget. Прогресс и завершение приходят в
// renderer через evtUpdaterStatus, ждать promise бессмысленно — renderer // renderer через evtUpdaterStatus, ждать promise бессмысленно — renderer
// только зря держал бы `busy=true` весь download (минуты на медленной сети). // только зря держал бы `busy=true` весь download (минуты на медленной сети).
ipcMain.on(IPC.updaterDownload, () => { safeOn(IPC.updaterDownload, () => {
void downloadUpdate() void downloadUpdate()
}) })
ipcMain.on(IPC.updaterInstall, () => quitAndInstall()) safeOn(IPC.updaterInstall, () => quitAndInstall())
// History // History
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs)) safeHandle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) => safeHandle(IPC.clearHistory, (_e, beforeTs?: number) => {
clearHistory(beforeTs) const removed = clearHistory(beforeTs)
) if (removed > 0) broadcastHistoryChanged()
return removed
})
// Export / Import. Используем native save/open dialogs Electron'а // Export / Import. Используем native save/open dialogs Electron'а
// renderer не получает прямого доступа к ФС. // renderer не получает прямого доступа к ФС.
ipcMain.handle(IPC.exportState, async (event) => { safeHandle(IPC.exportState, async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
const stamp = new Date() const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16)
.toISOString()
.replace(/[:T]/g, '-')
.slice(0, 16)
const defaultPath = `laude-backup-${stamp}.json` const defaultPath = `laude-backup-${stamp}.json`
// Native-диалоги OS читают локаль из системы. Title — единственная
// строка которую мы контролируем; локализуем по settings.language.
const lang = getState().settings.language ?? 'ru'
const result = await dialog.showSaveDialog(win!, { const result = await dialog.showSaveDialog(win!, {
title: 'Сохранить резервную копию', title: lang === 'en' ? 'Save backup' : 'Сохранить резервную копию',
defaultPath, defaultPath,
filters: [{ name: 'JSON', extensions: ['json'] }] 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 { try {
writeFileSync(result.filePath, exportState(), 'utf-8') writeFileSync(result.filePath, exportState(), 'utf-8')
return { ok: true, path: result.filePath } return { ok: true, canceled: false, path: result.filePath }
} catch (e) { } 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 win = BrowserWindow.fromWebContents(event.sender) ?? undefined
const lang = getState().settings.language ?? 'ru'
const result = await dialog.showOpenDialog(win!, { const result = await dialog.showOpenDialog(win!, {
title: 'Восстановить из резервной копии', title:
lang === 'en'
? 'Restore from backup'
: 'Восстановить из резервной копии',
properties: ['openFile'], properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }] filters: [{ name: 'JSON', extensions: ['json'] }]
}) })
if (result.canceled || result.filePaths.length === 0) { if (result.canceled || result.filePaths.length === 0) {
return { ok: false } return { ok: false, canceled: true }
} }
try { try {
const raw = readFileSync(result.filePaths[0], 'utf-8') const raw = readFileSync(result.filePaths[0], 'utf-8')
const ok = importState(raw) const ok = importState(raw)
if (ok) broadcastState() if (ok) {
return { ok } broadcastState()
broadcastHistoryChanged()
}
return { ok, canceled: false }
} catch (e) { } catch (e) {
return { ok: false, error: String(e) } return { ok: false, canceled: false, error: String(e) }
} }
}) })
} }

View File

@@ -121,5 +121,6 @@ export const log = {
/** Путь к логам (для диагностики). Возвращает пустую строку до initLogger(). */ /** Путь к логам (для диагностики). Возвращает пустую строку до initLogger(). */
export function getLogDir(): string { export function getLogDir(): string {
ensurePaths()
return logDir return logDir
} }

View File

@@ -0,0 +1,121 @@
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() }
}))
const originalPlatform = process.platform
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')
}
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true
})
}
beforeEach(() => {
vi.resetModules()
setPlatform('win32')
h.calls = 0
h.execImpl = (_cmd, _opts, cb) => cb(null, { stdout: '' })
h.log.info.mockClear()
h.log.warn.mockClear()
})
afterEach(() => {
setPlatform(originalPlatform)
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('zoom.exe') })
const { isMeetingActive } = await load()
await isMeetingActive()
await isMeetingActive()
expect(h.calls).toBe(1)
})
it('не считает Discord встречей', async () => {
h.execImpl = (_c, _o, cb) => cb(null, { stdout: csv('discord.exe') })
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(false)
})
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 () => {
setPlatform('linux')
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(false)
expect(h.calls).toBe(0)
})
})

View File

@@ -1,7 +1,7 @@
/** /**
* Эвристическое обнаружение «человек на ВКС» по списку запущенных процессов. * Эвристическое обнаружение «человек на ВКС» по списку запущенных процессов.
* *
* Идея: если запущен Zoom/Teams/Discord/Meet/Webex — пользователь скорее * Идея: если запущен Zoom/Teams/Meet/Webex — пользователь скорее
* всего на встрече или собирается зайти. Останавливаем напоминания, чтобы * всего на встрече или собирается зайти. Останавливаем напоминания, чтобы
* не прерывать. После «снятия» процессов возобновляем. * не прерывать. После «снятия» процессов возобновляем.
* *
@@ -36,7 +36,6 @@ const MEETING_PROCESSES = new Set([
'zoom.exe', 'zoom.exe',
'teams.exe', 'teams.exe',
'ms-teams.exe', // новые Teams 2.0 'ms-teams.exe', // новые Teams 2.0
'discord.exe',
'webex.exe', 'webex.exe',
'webexmta.exe', 'webexmta.exe',
'meet.exe', // Google Meet desktop (редкость) 'meet.exe', // Google Meet desktop (редкость)
@@ -64,7 +63,12 @@ export async function isMeetingActive(): Promise<boolean> {
// CSV без заголовков (/NH), скрытое окно. // CSV без заголовков (/NH), скрытое окно.
const { stdout } = await execAsync('tasklist /FO CSV /NH', { const { stdout } = await execAsync('tasklist /FO CSV /NH', {
windowsHide: true, 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() const lower = stdout.toLowerCase()
for (const proc of MEETING_PROCESSES) { for (const proc of MEETING_PROCESSES) {

View File

@@ -1,5 +1,10 @@
import { Notification, app } from 'electron' 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 { IPC } from '@shared/ipc'
import { import {
createReminderWindow, createReminderWindow,
@@ -12,6 +17,35 @@ export function fireReminder(exercise: Exercise, mode: NotificationMode): void {
if (mode === 'modal' || mode === 'both') showModal(exercise) 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 { export function fireMatchSummary(summary: MatchSummary): void {
if (Notification.isSupported()) { if (Notification.isSupported()) {
const totalReps = summary.results.reduce((s, r) => s + r.reps, 0) const totalReps = summary.results.reduce((s, r) => s + r.reps, 0)

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

@@ -0,0 +1,257 @@
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('dailyGoal использует reps snapshot из истории, а не текущие reps упражнения', async () => {
h.exercises = [makeExercise({ reps: 25, dailyGoal: 20 })]
h.history = [
{
ts: Date.now(),
exerciseId: 'ex1',
action: 'done',
reps: 10
}
]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).toHaveBeenCalledTimes(1)
})
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,16 +1,24 @@
import { powerMonitor, BrowserWindow } from 'electron' import { powerMonitor, BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import type { Exercise, Tick, HistoryEntry } from '@shared/types' import type { Exercise, Tick, HistoryEntry } from '@shared/types'
import { isQuietAt } from '@shared/types' import { isQuietAt, nextMealOccurrence } from '@shared/types'
import { getExercises, getHistory, getSettings, updateExercise } from './store' import {
import { fireReminder } from './notifications' getExercises,
getHistory,
getMeals,
getSettings,
updateExercise,
updateMeal
} from './store'
import { fireMealReminder, fireReminder } from './notifications'
import { broadcastState } from './state-actions' import { broadcastState } from './state-actions'
import { isMeetingActiveSync, refreshMeetingState } from './meeting-detect' import { isMeetingActiveSync, refreshMeetingState } from './meeting-detect'
import { adjustNextFireAt } from './adaptive' import { adjustNextFireAt } from './adaptive'
/** /**
* Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day). * Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day).
* Учитываем actualReps если задано (частичное выполнение), иначе planned reps. * Учитываем actualReps если задано (частичное выполнение), затем snapshot
* reps из истории, и только потом текущие planned reps упражнения.
*/ */
function repsDoneToday(ex: Exercise, history: HistoryEntry[]): number { function repsDoneToday(ex: Exercise, history: HistoryEntry[]): number {
const todayKey = new Date() const todayKey = new Date()
@@ -21,7 +29,7 @@ function repsDoneToday(ex: Exercise, history: HistoryEntry[]): number {
if (e.action !== 'done') continue if (e.action !== 'done') continue
if (e.exerciseId !== ex.id) continue if (e.exerciseId !== ex.id) continue
if (e.ts < startMs) continue if (e.ts < startMs) continue
sum += e.actualReps ?? ex.reps sum += e.actualReps ?? e.reps ?? ex.reps
} }
return sum return sum
} }
@@ -95,6 +103,39 @@ function checkDueExercises(): void {
if (anyFired) broadcastState() 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 { function broadcastTicks(): void {
const now = Date.now() const now = Date.now()
const ticks: Tick[] = getExercises().map((e) => ({ const ticks: Tick[] = getExercises().map((e) => ({
@@ -113,6 +154,7 @@ function tick(): void {
if (now - lastCheckAt >= CHECK_MS) { if (now - lastCheckAt >= CHECK_MS) {
lastCheckAt = now lastCheckAt = now
checkDueExercises() checkDueExercises()
checkDueMeals()
} }
} }

37
src/main/security.ts Normal file
View File

@@ -0,0 +1,37 @@
import { app, session } from 'electron'
import { log } from './logger'
/**
* Renderer permissions are denied by default. The app does not need camera,
* microphone, geolocation, USB/HID/serial, direct notification permission or
* browser-driven openExternal; all trusted privileged actions go through
* validated IPC handlers in main.
*/
export function installSecurityHardening(): void {
session.defaultSession.setPermissionRequestHandler(
(_webContents, permission, callback, details) => {
log.warn('[security] denied permission request', {
permission,
requestingUrl: 'requestingUrl' in details ? details.requestingUrl : ''
})
callback(false)
}
)
session.defaultSession.setPermissionCheckHandler(
(_webContents, permission, requestingOrigin) => {
log.warn('[security] denied permission check', {
permission,
requestingOrigin
})
return false
}
)
app.on('web-contents-created', (_event, contents) => {
contents.on('will-attach-webview', (event) => {
event.preventDefault()
log.warn('[security] blocked webview attach')
})
})
}

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 { export function snoozeAll(minutes: number): void {
const now = Date.now() const now = Date.now()
for (const ex of getExercises()) { for (const ex of getExercises()) {

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

@@ -0,0 +1,311 @@
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'
import { DEFAULT_SETTINGS } from '@shared/types'
/**
* Тесты 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 · meal history', () => {
it('markMealDone пишет meal-entry в историю', async () => {
writeFileSync(
statePath(),
JSON.stringify({
exercises: [],
meals: [
{
id: 'm1',
name: 'Обед',
time: '13:00',
icon: 'Soup',
enabled: true,
days: [],
nextFireAt: Date.now() + 60_000
}
],
challenges: [],
history: []
}),
'utf-8'
)
const { markMealDone, getHistory } = await load()
expect(markMealDone('m1')).toBeDefined()
expect(getHistory()).toMatchObject([
{
exerciseId: 'meal:m1',
action: 'done',
reps: 1,
name: 'Обед',
source: 'meal'
}
])
})
})
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)
})
it('import сохраняет валидные части snapshot и отбрасывает повреждённые записи', async () => {
const validExercise = {
id: 'x1',
name: 'Тест',
reps: 10,
icon: 'Activity',
intervalMinutes: 30,
enabled: true,
nextFireAt: Date.now() + 1000
}
const validMeal = {
id: 'm1',
name: 'Обед',
time: '13:00',
icon: 'Soup',
enabled: true,
days: [],
nextFireAt: Date.now() + 1000
}
const validChallenge = {
id: 'c1',
name: 'За убийства',
gameId: 'dota2',
stat: 'kills',
multiplier: 1,
exerciseName: 'Отжимания',
icon: 'Dumbbell',
enabled: true
}
const { importState, getState, getSettings, getHistory } = await load()
expect(
importState(
JSON.stringify({
exercises: [
validExercise,
{ ...validExercise, id: 'bad-ex', intervalMinutes: -5 }
],
meals: [validMeal, { ...validMeal, id: 'bad-meal', time: '25:00' }],
settings: {
globalEnabled: false,
snoozeMinutes: -1,
language: 'xx'
},
challenges: [
validChallenge,
{ ...validChallenge, id: 'bad-challenge', gameId: 'cs2' }
],
gamesEnabled: { dota2: true, cs2: true },
history: [
{ ts: 100, exerciseId: 'x1', action: 'done', reps: 10 },
{ ts: -1, exerciseId: 'x1', action: 'done', reps: 10 },
{
ts: 200,
exerciseId: 'meal:m1',
action: 'done',
reps: 1,
source: 'meal'
}
]
})
)
).toBe(true)
const state = getState()
expect(state.exercises.map((e) => e.id)).toEqual(['x1'])
expect(state.meals.map((m) => m.id)).toEqual(['m1'])
expect(state.challenges.map((c) => c.id)).toEqual(['c1'])
expect(state.gamesEnabled).toEqual({ dota2: true })
expect(getHistory().map((e) => e.ts)).toEqual([100, 200])
expect(getSettings().globalEnabled).toBe(false)
expect(getSettings().snoozeMinutes).toBe(DEFAULT_SETTINGS.snoozeMinutes)
expect(getSettings().language).toBe(DEFAULT_SETTINGS.language)
})
})

View File

@@ -17,11 +17,22 @@ import {
GameId, GameId,
HistoryAction, HistoryAction,
HistoryEntry, HistoryEntry,
HistorySource,
Meal,
nextMealOccurrence,
PersistedState, PersistedState,
SAMPLE_EXERCISES, SAMPLE_EXERCISES,
SAMPLE_MEALS,
Settings Settings
} from '@shared/types' } from '@shared/types'
import { log } from './logger' import { log } from './logger'
import {
validateChallengeInput,
validateExerciseInput,
validateId,
validateMealInput,
validateSettingsPatch
} from './validate'
/** /**
* Keep at most this many history entries (≈2.7 years at 10/day). * Keep at most this many history entries (≈2.7 years at 10/day).
@@ -36,7 +47,7 @@ let cache: PersistedState | null = null
let storePath = '' let storePath = ''
let pendingWrite: NodeJS.Timeout | null = null let pendingWrite: NodeJS.Timeout | null = null
function getStorePath(): string { export function getStorePath(): string {
if (!storePath) { if (!storePath) {
const dir = app.getPath('userData') const dir = app.getPath('userData')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
@@ -53,6 +64,11 @@ function makeInitial(): PersistedState {
id: randomUUID(), id: randomUUID(),
nextFireAt: now + e.intervalMinutes * 60_000 nextFireAt: now + e.intervalMinutes * 60_000
})), })),
meals: SAMPLE_MEALS.map((m) => ({
...m,
id: randomUUID(),
nextFireAt: nextMealOccurrence(m.time, m.days, now)
})),
settings: { ...DEFAULT_SETTINGS }, settings: { ...DEFAULT_SETTINGS },
challenges: [ challenges: [
{ {
@@ -102,6 +118,137 @@ function isValidParsed(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v) return typeof v === 'object' && v !== null && !Array.isArray(v)
} }
function finiteMs(v: unknown): number | undefined {
return typeof v === 'number' &&
Number.isFinite(v) &&
v >= 0 &&
v <= Number.MAX_SAFE_INTEGER
? v
: undefined
}
function intInRange(v: unknown, min: number, max: number): number | undefined {
if (typeof v !== 'number' || !Number.isFinite(v)) return undefined
const n = Math.trunc(v)
return n >= min && n <= max ? n : undefined
}
function safeStr(v: unknown, max = 200): string | undefined {
if (typeof v !== 'string') return undefined
if (v.length === 0 || v.length > max) return undefined
return v
}
const SETTINGS_KEYS: (keyof Settings)[] = [
'globalEnabled',
'notificationMode',
'soundEnabled',
'voicePromptsEnabled',
'meetingAutoPause',
'startWithWindows',
'minimizeToTray',
'startMinimized',
'theme',
'language',
'snoozeMinutes',
'quietHours',
'lastSeenVersion'
]
const GAME_IDS: GameId[] = ['dota2']
const HISTORY_ACTIONS: HistoryAction[] = ['done', 'skip', 'snooze']
const HISTORY_SOURCES: HistorySource[] = ['reminder', 'meal', 'match']
function sanitizeSettings(raw: unknown): Settings {
const out: Settings = { ...DEFAULT_SETTINGS }
if (!isValidParsed(raw)) return out
for (const key of SETTINGS_KEYS) {
if (!(key in raw)) continue
const patch = validateSettingsPatch({ [key]: raw[key] })
if (patch) Object.assign(out, patch)
}
return out
}
function sanitizeExercise(raw: unknown, now = Date.now()): Exercise | null {
if (!isValidParsed(raw)) return null
const id = validateId(raw.id)
const base = validateExerciseInput(raw)
if (!id || !base) return null
const exercise: Exercise = {
...base,
id,
nextFireAt: finiteMs(raw.nextFireAt) ?? now + base.intervalMinutes * 60_000
}
const lastDoneAt = finiteMs(raw.lastDoneAt)
if (lastDoneAt !== undefined) exercise.lastDoneAt = lastDoneAt
return exercise
}
function sanitizeMeal(raw: unknown, now = Date.now()): Meal | null {
if (!isValidParsed(raw)) return null
const id = validateId(raw.id)
const base = validateMealInput(raw)
if (!id || !base) return null
const meal: Meal = {
...base,
id,
nextFireAt:
finiteMs(raw.nextFireAt) ?? nextMealOccurrence(base.time, base.days, now)
}
const lastDoneAt = finiteMs(raw.lastDoneAt)
if (lastDoneAt !== undefined) meal.lastDoneAt = lastDoneAt
return meal
}
function sanitizeChallenge(raw: unknown): Challenge | null {
if (!isValidParsed(raw)) return null
const id = validateId(raw.id)
const base = validateChallengeInput(raw)
if (!id || !base) return null
return { ...base, id }
}
function sanitizeGamesEnabled(raw: unknown): Partial<Record<GameId, boolean>> {
const out: Partial<Record<GameId, boolean>> = {}
if (!isValidParsed(raw)) return out
for (const id of GAME_IDS) {
if (typeof raw[id] === 'boolean') out[id] = raw[id]
}
return out
}
function sanitizeHistoryEntry(raw: unknown): HistoryEntry | null {
if (!isValidParsed(raw)) return null
const ts = finiteMs(raw.ts)
const exerciseId = validateId(raw.exerciseId)
const action =
typeof raw.action === 'string' &&
HISTORY_ACTIONS.includes(raw.action as HistoryAction)
? (raw.action as HistoryAction)
: undefined
if (ts === undefined || !exerciseId || action === undefined) return null
const entry: HistoryEntry = { ts, exerciseId, action }
const actualReps = intInRange(raw.actualReps, 0, 100_000)
if (actualReps !== undefined) entry.actualReps = actualReps
const reps = intInRange(raw.reps, 0, 100_000)
if (reps !== undefined) entry.reps = reps
const name = safeStr(raw.name)
if (name !== undefined) entry.name = name
if (
typeof raw.source === 'string' &&
HISTORY_SOURCES.includes(raw.source as HistorySource)
) {
entry.source = raw.source as HistorySource
}
return entry
}
/** /**
* Current persisted-state schema version. Bump this and add a migration to * Current persisted-state schema version. Bump this and add a migration to
* MIGRATIONS whenever the on-disk shape changes in a non-additive way. * MIGRATIONS whenever the on-disk shape changes in a non-additive way.
@@ -147,19 +294,36 @@ function runMigrations(s: StoredState): StoredState {
/** Coerce a (possibly partial) migrated state into a fully-formed PersistedState. */ /** Coerce a (possibly partial) migrated state into a fully-formed PersistedState. */
function coerce(s: StoredState): PersistedState { function coerce(s: StoredState): PersistedState {
const now = Date.now()
return { return {
exercises: Array.isArray(s.exercises) ? (s.exercises as Exercise[]) : [], exercises: Array.isArray(s.exercises)
settings: { ? s.exercises.flatMap((raw) => {
...DEFAULT_SETTINGS, const exercise = sanitizeExercise(raw, now)
...(isValidParsed(s.settings) ? (s.settings as Partial<Settings>) : {}) return exercise ? [exercise] : []
}, })
challenges: Array.isArray(s.challenges)
? (s.challenges as Challenge[])
: [], : [],
gamesEnabled: isValidParsed(s.gamesEnabled) // Additive: старые state'ы без `meals` получают пустой список (см. философию
? (s.gamesEnabled as Partial<Record<GameId, boolean>>) // миграций — additive-поля не требуют bump'а схемы).
: {}, meals: Array.isArray(s.meals)
history: Array.isArray(s.history) ? (s.history as HistoryEntry[]) : [] ? s.meals.flatMap((raw) => {
const meal = sanitizeMeal(raw, now)
return meal ? [meal] : []
})
: [],
settings: sanitizeSettings(s.settings),
challenges: Array.isArray(s.challenges)
? s.challenges.flatMap((raw) => {
const challenge = sanitizeChallenge(raw)
return challenge ? [challenge] : []
})
: [],
gamesEnabled: sanitizeGamesEnabled(s.gamesEnabled),
history: Array.isArray(s.history)
? s.history.flatMap((raw) => {
const entry = sanitizeHistoryEntry(raw)
return entry ? [entry] : []
})
: []
} }
} }
@@ -303,11 +467,11 @@ function atomicWriteSync(path: string, contents: string): void {
} }
const delay = WRITE_RETRY_DELAYS[i] const delay = WRITE_RETRY_DELAYS[i]
if (delay === undefined) break if (delay === undefined) break
// Event-loop остановлен, async sleep не вернётся — приходится spin. // Event-loop остановлен (exit-path), async sleep не вернётся — нужен
const until = Date.now() + delay // блокирующий sync sleep. Atomics.wait на «свежем» буфере всегда уходит
while (Date.now() < until) { // в таймаут (значение совпадает с ожидаемым 0), т.е. честно спит delay мс
/* spin */ // без сжигания CPU — в отличие от старого busy-loop.
} Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
} }
} }
log.error('[store] atomic sync write failed after retries', lastErr) log.error('[store] atomic sync write failed after retries', lastErr)
@@ -360,6 +524,7 @@ export function getStateForRenderer(): AppState {
const p = getState() const p = getState()
return { return {
exercises: p.exercises, exercises: p.exercises,
meals: p.meals,
settings: p.settings, settings: p.settings,
challenges: p.challenges, challenges: p.challenges,
gamesEnabled: p.gamesEnabled gamesEnabled: p.gamesEnabled
@@ -467,6 +632,79 @@ export function skip(id: string): Exercise | undefined {
return ex 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())
}
appendHistory(`meal:${id}`, 'done', {
reps: 1,
name: meal.name,
source: 'meal'
})
scheduleWrite()
return meal
}
/** /**
* Записать выполнение челленджа из match summary в историю. Не привязано * Записать выполнение челленджа из match summary в историю. Не привязано
* к конкретному Exercise (челлендж может ссылаться на упражнение, которое * к конкретному Exercise (челлендж может ссылаться на упражнение, которое
@@ -561,8 +799,8 @@ export function exportState(): string {
/** /**
* Импорт snapshot'а. Перезаписывает текущий state. Возвращает true при * Импорт snapshot'а. Перезаписывает текущий state. Возвращает true при
* успехе. Идёт через тот же coerce + runMigrations что и load() — это * успехе. Идёт через тот же coerce + runMigrations что и load(): валидные
* валидирует тип/диапазоны. * записи сохраняются, повреждённые записи/поля отбрасываются.
* *
* НЕ объединяет с текущим state (merge сложен: дубликаты id, конфликты * НЕ объединяет с текущим state (merge сложен: дубликаты id, конфликты
* settings) — простое replace. Перед импортом UI должен спросить * settings) — простое replace. Перед импортом UI должен спросить

View File

@@ -127,7 +127,15 @@ async function bootCheckWithRetry(): Promise<void> {
return // success return // success
} }
const delay = BOOT_RETRY_DELAYS[attempt] 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)) await new Promise((r) => setTimeout(r, delay))
} }
} }

View File

@@ -18,12 +18,86 @@ import {
validateExercisePatch, validateExercisePatch,
validateChallengeInput, validateChallengeInput,
validateChallengePatch, validateChallengePatch,
validateMealInput,
validateMealPatch,
validateSettingsPatch, validateSettingsPatch,
validateId, validateId,
validateActualReps, validateActualReps,
validateSnoozeMinutes validateSnoozeMinutes
} from './validate' } 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 = { const validExercise = {
name: 'Push-ups', name: 'Push-ups',
reps: 10, reps: 10,
@@ -46,8 +120,12 @@ describe('validateExerciseInput', () => {
}) })
it('rejects missing required fields', () => { it('rejects missing required fields', () => {
expect(validateExerciseInput({ ...validExercise, name: undefined })).toBeNull() expect(
expect(validateExerciseInput({ ...validExercise, reps: undefined })).toBeNull() validateExerciseInput({ ...validExercise, name: undefined })
).toBeNull()
expect(
validateExerciseInput({ ...validExercise, reps: undefined })
).toBeNull()
expect( expect(
validateExerciseInput({ ...validExercise, intervalMinutes: undefined }) validateExerciseInput({ ...validExercise, intervalMinutes: undefined })
).toBeNull() ).toBeNull()
@@ -143,7 +221,9 @@ describe('validateExercisePatch', () => {
it('accepts partial patches', () => { it('accepts partial patches', () => {
expect(validateExercisePatch({ reps: 12 })).toEqual({ reps: 12 }) expect(validateExercisePatch({ reps: 12 })).toEqual({ reps: 12 })
expect(validateExercisePatch({ name: 'New' })).toEqual({ name: 'New' }) expect(validateExercisePatch({ name: 'New' })).toEqual({ name: 'New' })
expect(validateExercisePatch({ enabled: false })).toEqual({ enabled: false }) expect(validateExercisePatch({ enabled: false })).toEqual({
enabled: false
})
}) })
it('rejects patch with a single invalid field', () => { it('rejects patch with a single invalid field', () => {
@@ -195,7 +275,14 @@ describe('validateChallengeInput', () => {
}) })
it('accepts all valid stats', () => { it('accepts all valid stats', () => {
const stats = ['deaths', 'kills', 'assists', 'last_hits', 'denies', 'duration_min'] const stats = [
'deaths',
'kills',
'assists',
'last_hits',
'denies',
'duration_min'
]
for (const stat of stats) { for (const stat of stats) {
expect(validateChallengeInput({ ...valid, stat })).not.toBeNull() expect(validateChallengeInput({ ...valid, stat })).not.toBeNull()
} }
@@ -210,11 +297,15 @@ describe('validateChallengeInput', () => {
}) })
it('accepts zero multiplier (legitimate "disable" semantics)', () => { it('accepts zero multiplier (legitimate "disable" semantics)', () => {
expect(validateChallengeInput({ ...valid, multiplier: 0 })?.multiplier).toBe(0) expect(
validateChallengeInput({ ...valid, multiplier: 0 })?.multiplier
).toBe(0)
}) })
it('accepts fractional multiplier (e.g. 0.5×)', () => { it('accepts fractional multiplier (e.g. 0.5×)', () => {
expect(validateChallengeInput({ ...valid, multiplier: 0.5 })?.multiplier).toBe(0.5) expect(
validateChallengeInput({ ...valid, multiplier: 0.5 })?.multiplier
).toBe(0.5)
}) })
}) })
@@ -266,6 +357,67 @@ describe('validateSettingsPatch', () => {
expect(validateSettingsPatch({ snoozeMinutes: -5 })).toBeNull() 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', () => { describe('quietHours subobject', () => {
const baseQh = { const baseQh = {
enabled: true, enabled: true,
@@ -289,6 +441,12 @@ describe('validateSettingsPatch', () => {
expect( expect(
validateSettingsPatch({ quietHours: { ...baseQh, from: '2500' } }) validateSettingsPatch({ quietHours: { ...baseQh, from: '2500' } })
).toBeNull() ).toBeNull()
expect(
validateSettingsPatch({ quietHours: { ...baseQh, from: '25:00' } })
).toBeNull()
expect(
validateSettingsPatch({ quietHours: { ...baseQh, to: '09:99' } })
).toBeNull()
expect( expect(
validateSettingsPatch({ quietHours: { ...baseQh, to: 'bedtime' } }) validateSettingsPatch({ quietHours: { ...baseQh, to: 'bedtime' } })
).toBeNull() ).toBeNull()
@@ -342,7 +500,9 @@ describe('validateSettingsPatch', () => {
describe('validateId', () => { describe('validateId', () => {
it('accepts reasonable id strings', () => { it('accepts reasonable id strings', () => {
expect(validateId('abc')).toBe('abc') expect(validateId('abc')).toBe('abc')
expect(validateId('uuid-v4-style-thing-123')).toBe('uuid-v4-style-thing-123') expect(validateId('uuid-v4-style-thing-123')).toBe(
'uuid-v4-style-thing-123'
)
}) })
it('rejects non-strings', () => { it('rejects non-strings', () => {

View File

@@ -14,7 +14,9 @@
import type { import type {
Challenge, Challenge,
Exercise, Exercise,
GameId,
GameStat, GameStat,
Meal,
Settings, Settings,
Theme, Theme,
Language, Language,
@@ -26,6 +28,7 @@ const MAX_STR_LEN = 200
const VALID_THEMES: Theme[] = ['system', 'light', 'dark'] const VALID_THEMES: Theme[] = ['system', 'light', 'dark']
const VALID_LANGS: Language[] = ['ru', 'en'] const VALID_LANGS: Language[] = ['ru', 'en']
const VALID_NOTIFY: NotificationMode[] = ['toast', 'modal', 'both'] const VALID_NOTIFY: NotificationMode[] = ['toast', 'modal', 'both']
const VALID_GAME_IDS: GameId[] = ['dota2']
const VALID_STATS: GameStat[] = [ const VALID_STATS: GameStat[] = [
'deaths', 'deaths',
'kills', 'kills',
@@ -40,7 +43,6 @@ const VALID_CATEGORIES: ReminderCategory[] = [
'eyes', 'eyes',
'posture' 'posture'
] ]
const HHMM_RE = /^\d{1,2}:\d{2}$/
function isObj(v: unknown): v is Record<string, unknown> { function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v) return typeof v === 'object' && v !== null && !Array.isArray(v)
@@ -78,6 +80,34 @@ function oneOf<T extends string>(
: undefined : 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 // Exercise validators
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -99,7 +129,11 @@ export function validateExerciseInput(
// dailyGoal: undefined = не задан (нет soft-cap'a), null от UI приводим к // dailyGoal: undefined = не задан (нет soft-cap'a), null от UI приводим к
// undefined; иначе — должен пройти int-range, иначе reject (нельзя // undefined; иначе — должен пройти int-range, иначе reject (нельзя
// отправить из renderer'а NaN/негатив и тихо обнулить). // отправить из renderer'а NaN/негатив и тихо обнулить).
if (raw.dailyGoal !== undefined && raw.dailyGoal !== null && dailyGoal === undefined) { if (
raw.dailyGoal !== undefined &&
raw.dailyGoal !== null &&
dailyGoal === undefined
) {
return null return null
} }
if ( if (
@@ -188,6 +222,69 @@ export function validateExercisePatch(
return out 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 // Challenge validators
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -197,7 +294,7 @@ export function validateChallengeInput(
): Omit<Challenge, 'id'> | null { ): Omit<Challenge, 'id'> | null {
if (!isObj(raw)) return null if (!isObj(raw)) return null
const name = safeStr(raw.name) const name = safeStr(raw.name)
const gameId = safeStr(raw.gameId, 32) const gameId = oneOf(raw.gameId, VALID_GAME_IDS)
const stat = oneOf(raw.stat, VALID_STATS) const stat = oneOf(raw.stat, VALID_STATS)
const multiplier = numInRange(raw.multiplier, 0, 1000) const multiplier = numInRange(raw.multiplier, 0, 1000)
const exerciseName = safeStr(raw.exerciseName) const exerciseName = safeStr(raw.exerciseName)
@@ -214,7 +311,7 @@ export function validateChallengeInput(
} }
return { return {
name, name,
gameId: gameId as Challenge['gameId'], gameId,
stat, stat,
multiplier, multiplier,
exerciseName, exerciseName,
@@ -344,8 +441,8 @@ export function validateSettingsPatch(raw: unknown): Partial<Settings> | null {
enabled === undefined || enabled === undefined ||
from === undefined || from === undefined ||
to === undefined || to === undefined ||
!HHMM_RE.test(from) || validHHMM(from) === undefined ||
!HHMM_RE.test(to) validHHMM(to) === undefined
) { ) {
return null return null
} }

View File

@@ -3,11 +3,14 @@ import { IPC } from '@shared/ipc'
import type { import type {
AppState, AppState,
Challenge, Challenge,
DiagnosticsInfo,
Exercise, Exercise,
GameId, GameId,
GameStatus, GameStatus,
HistoryEntry, HistoryEntry,
MatchSummary, MatchSummary,
Meal,
RendererErrorReport,
Settings, Settings,
Tick, Tick,
UpdaterStatus UpdaterStatus
@@ -41,6 +44,19 @@ const api = {
ipcRenderer.invoke(IPC.snooze, id, minutes), ipcRenderer.invoke(IPC.snooze, id, minutes),
skip: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.skip, id), 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> => updateSettings: (patch: Partial<Settings>): Promise<Settings> =>
ipcRenderer.invoke(IPC.updateSettings, patch), ipcRenderer.invoke(IPC.updateSettings, patch),
@@ -50,6 +66,14 @@ const api = {
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.getAppVersion), getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.getAppVersion),
getMeetingActive: (): Promise<boolean> => getMeetingActive: (): Promise<boolean> =>
ipcRenderer.invoke(IPC.getMeetingActive), ipcRenderer.invoke(IPC.getMeetingActive),
getDiagnostics: (): Promise<DiagnosticsInfo> =>
ipcRenderer.invoke(IPC.getDiagnostics),
openLogsFolder: (): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke(IPC.openLogsFolder),
copyDiagnostics: (): Promise<DiagnosticsInfo> =>
ipcRenderer.invoke(IPC.copyDiagnostics),
reportRendererError: (report: RendererErrorReport): Promise<boolean> =>
ipcRenderer.invoke(IPC.reportRendererError, report),
pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll), pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll),
resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll), resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll),
@@ -122,13 +146,21 @@ const api = {
ipcRenderer.invoke(IPC.clearHistory, beforeTs), ipcRenderer.invoke(IPC.clearHistory, beforeTs),
// Export / Import — открывают native save/open dialogs из main process. // Export / Import — открывают native save/open dialogs из main process.
exportState: (): Promise<{ ok: boolean; path: string | null }> => exportState: (): Promise<{
ipcRenderer.invoke(IPC.exportState), ok: boolean
importState: (): Promise<{ ok: boolean; error?: string }> => canceled: boolean
ipcRenderer.invoke(IPC.importState), 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), onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, 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), onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),
onStateChanged: (h: Handler<AppState>): Unsub => on(IPC.evtStateChanged, h), onStateChanged: (h: Handler<AppState>): Unsub => on(IPC.evtStateChanged, h),
onThemeChanged: (h: Handler<'light' | 'dark'>): Unsub => onThemeChanged: (h: Handler<'light' | 'dark'>): Unsub =>
@@ -141,7 +173,8 @@ const api = {
onMaximizeChanged: (h: Handler<boolean>): Unsub => onMaximizeChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMaximizeChanged, h), on(IPC.evtMaximizeChanged, h),
onMeetingChanged: (h: Handler<boolean>): Unsub => 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) contextBridge.exposeInMainWorld('api', api)

View File

@@ -9,8 +9,11 @@
для Tailwind utility-классов и инлайн-стилей framer-motion. font-src для Tailwind utility-классов и инлайн-стилей framer-motion. font-src
включает data: на случай если кто-то вставит base64 SVG-glyph. включает data: на случай если кто-то вставит base64 SVG-glyph.
--> -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; script-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" /> <meta
<title>Exercise Reminder</title> http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; script-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'"
/>
<title>Разомнись</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -5,9 +5,11 @@ import { Sidebar } from './components/Sidebar'
import { Titlebar } from './components/Titlebar' import { Titlebar } from './components/Titlebar'
import { ErrorBoundary } from './components/ErrorBoundary' import { ErrorBoundary } from './components/ErrorBoundary'
import { WhatsNewModal } from './components/WhatsNewModal' import { WhatsNewModal } from './components/WhatsNewModal'
import { Skeleton } from './components/ui/Skeleton'
import { unseenVersions } from '@shared/release-notes' import { unseenVersions } from '@shared/release-notes'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Exercises from './pages/Exercises' import Exercises from './pages/Exercises'
import Meals from './pages/Meals'
import GamesPage from './pages/Games' import GamesPage from './pages/Games'
import ChallengesPage from './pages/Challenges' import ChallengesPage from './pages/Challenges'
import SettingsPage from './pages/Settings' import SettingsPage from './pages/Settings'
@@ -20,7 +22,7 @@ let backendSubscribed = false
export default function App(): JSX.Element { export default function App(): JSX.Element {
const hydrated = useAppStore((s) => s.hydrated) const hydrated = useAppStore((s) => s.hydrated)
const settings = useAppStore((s) => s.state?.settings) const settings = useAppStore((s) => s.state?.settings)
const [mobileNavOpen, setMobileNavOpen] = useState(false) const [compactNavOpen, setCompactNavOpen] = useState(false)
const [whatsNew, setWhatsNew] = useState<{ const [whatsNew, setWhatsNew] = useState<{
open: boolean open: boolean
versions: string[] versions: string[]
@@ -88,20 +90,35 @@ export default function App(): JSX.Element {
<ErrorBoundary> <ErrorBoundary>
<HashRouter> <HashRouter>
<div className="h-screen w-screen flex flex-col bg-bg"> <div className="h-screen w-screen flex flex-col bg-bg">
<Titlebar onMenuClick={() => setMobileNavOpen(true)} /> <Titlebar onMenuClick={() => setCompactNavOpen(true)} />
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
<Sidebar <Sidebar
mobileOpen={mobileNavOpen} compactOpen={compactNavOpen}
onMobileClose={() => setMobileNavOpen(false)} onCompactClose={() => setCompactNavOpen(false)}
/> />
<main className="flex-1 overflow-hidden min-w-0"> <main className="flex-1 overflow-hidden min-w-0">
{hydrated ? ( {hydrated ? (
<ErrorBoundary> <ErrorBoundary>
<RoutedPages onNav={() => setMobileNavOpen(false)} /> <RoutedPages onNav={() => setCompactNavOpen(false)} />
</ErrorBoundary> </ErrorBoundary>
) : ( ) : (
// Neutral placeholder — settings (and lang) aren't loaded yet. // Skeleton на время гидрации — settings (и язык) ещё не
<div className="p-8 text-text/45" /> // загружены, текст показывать рано, но пустота выглядит как
// зависание. Каркас задаёт ожидание «сейчас появится контент».
<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> </main>
</div> </div>
@@ -136,6 +153,7 @@ function RoutedPages({ onNav }: { onNav: () => void }): JSX.Element {
<Routes location={location}> <Routes location={location}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/exercises" element={<Exercises />} /> <Route path="/exercises" element={<Exercises />} />
<Route path="/meals" element={<Meals />} />
<Route path="/games" element={<GamesPage />} /> <Route path="/games" element={<GamesPage />} />
<Route path="/challenges" element={<ChallengesPage />} /> <Route path="/challenges" element={<ChallengesPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />

View File

@@ -13,6 +13,7 @@ import {
import type { import type {
Exercise, Exercise,
MatchSummary, MatchSummary,
Meal,
Settings, Settings,
ChallengeResult, ChallengeResult,
Language Language
@@ -26,8 +27,11 @@ import { translate, translateN } from './i18n'
type Mode = type Mode =
| { kind: 'idle' } | { kind: 'idle' }
| { kind: 'exercise'; exercise: Exercise } | { kind: 'exercise'; exercise: Exercise }
| { kind: 'meal'; meal: Meal }
| { kind: 'match'; summary: MatchSummary; done: Set<string> } | { kind: 'match'; summary: MatchSummary; done: Set<string> }
type ActiveMode = Exclude<Mode, { kind: 'idle' }>
/** Минимальный нативный confirm. В reminder-окне нет места для модалки, /** Минимальный нативный confirm. В reminder-окне нет места для модалки,
* проще использовать встроенный диалог. */ * проще использовать встроенный диалог. */
function nativeConfirm(message: string): boolean { function nativeConfirm(message: string): boolean {
@@ -39,51 +43,42 @@ export default function ReminderApp(): JSX.Element {
const [mode, setMode] = useState<Mode>({ kind: 'idle' }) const [mode, setMode] = useState<Mode>({ kind: 'idle' })
const [settings, setSettings] = useState<Settings | null>(null) const [settings, setSettings] = useState<Settings | null>(null)
const settingsRef = useRef<Settings | null>(null) const settingsRef = useRef<Settings | null>(null)
const modeRef = useRef<Mode>({ kind: 'idle' })
const queueRef = useRef<ActiveMode[]>([])
// ChallengeId'ы, для которых уже отправили markChallengeDone IPC. ref,
// не state — нужен только для дедупа rapid double-click. Сбрасывается
// когда приходит новый match summary (см. onMatchEnd ниже).
const sentChallengesRef = useRef<Set<string>>(new Set())
useEffect(() => { useEffect(() => {
settingsRef.current = settings settingsRef.current = settings
}, [settings]) }, [settings])
useEffect(() => {
modeRef.current = mode
}, [mode])
useEffect(() => { useEffect(() => {
window.api.getState().then((s) => setSettings(s.settings)) window.api.getState().then((s) => setSettings(s.settings))
const u0 = window.api.onStateChanged((s) => setSettings(s.settings)) const u0 = window.api.onStateChanged((s) => setSettings(s.settings))
const u1 = window.api.onFire((ex) => { const u1 = window.api.onFire((ex) => {
setMode({ kind: 'exercise', exercise: ex }) enqueueMode({ kind: 'exercise', exercise: ex })
const s = settingsRef.current })
if (s?.soundEnabled) playBeep() const u1b = window.api.onFireMeal((meal) => {
if (s?.voicePromptsEnabled) { enqueueMode({ kind: 'meal', meal })
// Задержка 800ms даёт пользователю шанс decrement'нуть stepper до
// фактического количества — TTS прозвучит уже под реальную цифру,
// если успел нажать -. Иначе скажет планируемые reps.
const lang = s.language ?? 'ru'
setTimeout(() => {
const phrase =
lang === 'ru'
? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}`
: `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}`
speak(phrase, lang)
}, 800)
}
}) })
const u2 = window.api.onMatchEnd((summary) => { const u2 = window.api.onMatchEnd((summary) => {
setMode({ kind: 'match', summary, done: new Set() }) enqueueMode({ kind: 'match', summary, done: new Set() })
const s = settingsRef.current
if (s?.soundEnabled) playBeep()
if (s?.voicePromptsEnabled) {
const total = summary.results.reduce((acc, r) => acc + r.reps, 0)
const lang = s.language ?? 'ru'
const phrase =
lang === 'ru'
? `Матч завершён. ${total} ${repWordRu(total)} ждут.`
: `Match complete. ${total} ${total === 1 ? 'rep' : 'reps'} await.`
speak(phrase, lang)
}
}) })
return () => { return () => {
u0() u0()
u1() u1()
u1b()
u2() u2()
} }
// IPC-подписки должны жить один раз; enqueueMode читает актуальный mode
// через ref, поэтому зависимость здесь не нужна.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// ESC closes the match summary view too — keyboard parity with exercise mode. // ESC closes the match summary view too — keyboard parity with exercise mode.
@@ -97,6 +92,63 @@ export default function ReminderApp(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode.kind]) }, [mode.kind])
function enqueueMode(next: ActiveMode): void {
if (modeRef.current.kind === 'idle') {
activateMode(next)
return
}
queueRef.current.push(next)
}
function activateMode(next: ActiveMode): void {
if (next.kind === 'match') {
// Новый match summary получает чистый дедуп-сет только когда реально
// становится активным; иначе queued summary не сбивает текущий матч.
sentChallengesRef.current = new Set()
}
modeRef.current = next
setMode(next)
playAlertFor(next)
}
function playAlertFor(next: ActiveMode): void {
const s = settingsRef.current
if (s?.soundEnabled) playBeep()
if (!s?.voicePromptsEnabled) return
const lang = s.language ?? 'ru'
if (next.kind === 'exercise') {
const ex = next.exercise
// Задержка 800ms даёт пользователю шанс decrement'нуть stepper до
// фактического количества — TTS прозвучит уже под реальную цифру,
// если успел нажать -. Иначе скажет планируемые reps.
setTimeout(() => {
const phrase =
lang === 'ru'
? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}`
: `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}`
speak(phrase, lang)
}, 800)
return
}
if (next.kind === 'meal') {
const phrase =
lang === 'ru'
? `Пора поесть. ${next.meal.name}`
: `Time to eat. ${next.meal.name}`
speak(phrase, lang)
return
}
const total = next.summary.results.reduce((acc, r) => acc + r.reps, 0)
const phrase =
lang === 'ru'
? `Матч завершён. ${total} ${repWordRu(total)} ждут.`
: `Match complete. ${total} ${total === 1 ? 'rep' : 'reps'} await.`
speak(phrase, lang)
}
function close(): void { function close(): void {
// Если в Match Summary остались незакрытые челленджи — подтверждаем, // Если в Match Summary остались незакрытые челленджи — подтверждаем,
// чтобы пользователь не «пролетел» окно по привычке и не потерял // чтобы пользователь не «пролетел» окно по привычке и не потерял
@@ -119,6 +171,12 @@ export default function ReminderApp(): JSX.Element {
if (!nativeConfirm(msg)) return if (!nativeConfirm(msg)) return
} }
} }
const next = queueRef.current.shift()
if (next) {
activateMode(next)
return
}
modeRef.current = { kind: 'idle' }
setMode({ kind: 'idle' }) setMode({ kind: 'idle' })
window.api.reminderClose() window.api.reminderClose()
} }
@@ -139,12 +197,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 ( return (
<MatchSummaryView <MatchSummaryView
summary={mode.summary} summary={mode.summary}
done={mode.done} done={mode.done}
lang={lang} lang={lang}
onMarkDone={(id) => { onMarkDone={(id) => {
// Дедупликация: rapid double-click может два раза вызвать
// onMarkDone до того как `disabled={done}` доедет до DOM.
// Раньше это писало в историю дважды → лишние +N reps.
if (sentChallengesRef.current.has(id)) return
sentChallengesRef.current.add(id)
// 1) IPC: записываем в историю (раньше делали только локальный set, // 1) IPC: записываем в историю (раньше делали только локальный set,
// из-за чего матч-челленджи не считались в стрик/achievements). // из-за чего матч-челленджи не считались в стрик/achievements).
const result = mode.summary.results.find((r) => r.challengeId === id) const result = mode.summary.results.find((r) => r.challengeId === id)
@@ -153,13 +227,16 @@ export default function ReminderApp(): JSX.Element {
} }
// 2) Functional update: rapid-click race-safe. // 2) Functional update: rapid-click race-safe.
setMode((m) => setMode((m) =>
m.kind === 'match' {
? { if (m.kind !== 'match') return m
const nextMode: Mode = {
kind: 'match', kind: 'match',
summary: m.summary, summary: m.summary,
done: new Set([...m.done, id]) done: new Set([...m.done, id])
} }
: m modeRef.current = nextMode
return nextMode
}
) )
}} }}
onClose={close} onClose={close}
@@ -339,6 +416,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({ function MatchSummaryView({
summary, summary,
done, done,

View File

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

View File

@@ -23,9 +23,13 @@ export class ErrorBoundary extends Component<Props, State> {
} }
componentDidCatch(error: Error, info: ErrorInfo): void { componentDidCatch(error: Error, info: ErrorInfo): void {
// No remote telemetry — log to the local console so a curious user
// (or dev tools session) can capture it.
console.error('[ErrorBoundary]', error, info.componentStack) console.error('[ErrorBoundary]', error, info.componentStack)
void window.api?.reportRendererError?.({
source: 'ErrorBoundary',
message: error.message,
stack: error.stack,
componentStack: info.componentStack ?? undefined
})
} }
reset = (): void => this.setState({ error: null }) reset = (): void => this.setState({ error: null })

View File

@@ -1,11 +1,12 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Check, MoreHorizontal, Brain, CheckCircle2 } from 'lucide-react' 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 type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon' import { Icon } from '../lib/icon'
import { formatCountdown } from '../lib/format' import { formatCountdown } from '../lib/format'
import { Switch } from './ui/Switch' import { Switch } from './ui/Switch'
import { useT } from '../i18n' import { useT } from '../i18n'
import { useAnnounce } from '../lib/useAnnounce'
type Props = { type Props = {
exercise: Exercise exercise: Exercise
@@ -43,7 +44,64 @@ export function ExerciseCard({
// Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем. // Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем.
const isDue = ms <= 0 && exercise.enabled && !goalReached const isDue = ms <= 0 && exercise.enabled && !goalReached
const [menuOpen, setMenuOpen] = useState(false) 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 { 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 // Ring math
const R = 22 const R = 22
@@ -110,9 +168,12 @@ export function ExerciseCard({
</h3> </h3>
<div className="relative"> <div className="relative">
<button <button
ref={triggerRef}
onClick={() => setMenuOpen((v) => !v)} 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" 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-label={t('titlebar.menu_aria')}
aria-haspopup="menu"
aria-expanded={menuOpen}
> >
<MoreHorizontal size={16} /> <MoreHorizontal size={16} />
</button> </button>
@@ -122,8 +183,15 @@ export function ExerciseCard({
className="fixed inset-0 z-10" className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)} 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 <button
role="menuitem"
onClick={() => { onClick={() => {
setMenuOpen(false) setMenuOpen(false)
onEdit() onEdit()
@@ -133,6 +201,7 @@ export function ExerciseCard({
{t('btn.edit')} {t('btn.edit')}
</button> </button>
<button <button
role="menuitem"
onClick={() => { onClick={() => {
setMenuOpen(false) setMenuOpen(false)
onDelete() onDelete()
@@ -213,7 +282,7 @@ export function ExerciseCard({
<motion.button <motion.button
initial={{ opacity: 0, y: 4 }} initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }} 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" 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')} <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

@@ -0,0 +1,104 @@
import type { ReactNode } from 'react'
type Tone = 'accent' | 'success' | 'warning' | 'info' | 'muted'
export function PageHeader({
kicker,
title,
subtitle,
action
}: {
kicker: string
title: string
subtitle?: string
action?: ReactNode
}): JSX.Element {
return (
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div className="min-w-0">
<div className="text-[14px] text-text/65 font-semibold">{kicker}</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
{title}
</h1>
{subtitle && (
<p className="text-[15px] text-text/65 mt-2 font-medium leading-relaxed max-w-2xl">
{subtitle}
</p>
)}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
)
}
export function InsightGrid({
children,
className = ''
}: {
children: ReactNode
className?: string
}): JSX.Element {
return (
<div
className={[
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mb-8',
className
].join(' ')}
>
{children}
</div>
)
}
export function InsightCard({
icon,
label,
value,
hint,
tone = 'accent'
}: {
icon: ReactNode
label: string
value: string
hint?: string
tone?: Tone
}): JSX.Element {
const iconClass =
tone === 'accent'
? 'bg-accent text-white'
: tone === 'success'
? 'bg-success text-white'
: tone === 'warning'
? 'bg-warning text-white'
: tone === 'info'
? 'bg-info text-white'
: 'bg-text/12 text-text/55'
return (
<div className="bg-surface rounded-2xl p-4 shadow-card dark:ring-0.5 dark:ring-hairline/30 min-w-0">
<div className="flex items-start gap-3">
<div
className={[
'w-9 h-9 rounded-xl grid place-items-center shrink-0',
iconClass
].join(' ')}
>
{icon}
</div>
<div className="min-w-0">
<div className="text-[12px] uppercase tracking-[0.06em] text-text/50 font-bold leading-tight">
{label}
</div>
<div className="font-display text-[22px] font-bold leading-tight mt-1 break-words">
{value}
</div>
{hint && (
<div className="text-[13px] text-text/58 mt-2 leading-snug">
{hint}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,8 +1,17 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion' 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' import { useT } from '../i18n'
import { useAppStore } from '../store/appStore'
type Item = { type Item = {
to: string to: string
@@ -20,6 +29,12 @@ const items: Item[] = [
icon: Dumbbell, icon: Dumbbell,
tint: 'bg-info' 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: '/games', labelKey: 'nav.games', icon: Joystick, tint: 'bg-accent-2' },
{ {
to: '/challenges', to: '/challenges',
@@ -36,28 +51,28 @@ const items: Item[] = [
] ]
type Props = { type Props = {
mobileOpen?: boolean compactOpen?: boolean
onMobileClose?: () => void onCompactClose?: () => void
} }
export function Sidebar({ export function Sidebar({
mobileOpen = false, compactOpen = false,
onMobileClose onCompactClose
}: Props): JSX.Element { }: Props): JSX.Element {
const { t } = useT() const { t } = useT()
const drawerRef = useRef<HTMLElement | null>(null) const drawerRef = useRef<HTMLElement | null>(null)
const lastFocusedRef = useRef<HTMLElement | null>(null) const lastFocusedRef = useRef<HTMLElement | null>(null)
// Esc closes + focus trap while the mobile drawer is open. Mirrors the // Esc closes + focus trap while the compact drawer is open. Mirrors the
// pattern used in Modal.tsx. // pattern used in Modal.tsx for keyboard users.
useEffect(() => { useEffect(() => {
if (!mobileOpen) return undefined if (!compactOpen) return undefined
lastFocusedRef.current = document.activeElement as HTMLElement | null lastFocusedRef.current = document.activeElement as HTMLElement | null
const onKeyDown = (e: KeyboardEvent): void => { const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
onMobileClose?.() onCompactClose?.()
return return
} }
if (e.key !== 'Tab') return if (e.key !== 'Tab') return
@@ -90,7 +105,7 @@ export function Sidebar({
const target = lastFocusedRef.current const target = lastFocusedRef.current
if (target && document.body.contains(target)) target.focus() if (target && document.body.contains(target)) target.focus()
} }
}, [mobileOpen, onMobileClose]) }, [compactOpen, onCompactClose])
return ( return (
<> <>
@@ -99,7 +114,7 @@ export function Sidebar({
</aside> </aside>
<AnimatePresence> <AnimatePresence>
{mobileOpen && ( {compactOpen && (
<motion.div <motion.div
className="md:hidden fixed inset-0 z-50 flex" className="md:hidden fixed inset-0 z-50 flex"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@@ -109,7 +124,7 @@ export function Sidebar({
> >
<motion.div <motion.div
className="absolute inset-0 bg-black/30 backdrop-blur-md" className="absolute inset-0 bg-black/30 backdrop-blur-md"
onClick={onMobileClose} onClick={onCompactClose}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
@@ -127,13 +142,13 @@ export function Sidebar({
transition={{ type: 'spring', stiffness: 420, damping: 38 }} transition={{ type: 'spring', stiffness: 420, damping: 38 }}
> >
<button <button
onClick={onMobileClose} onClick={onCompactClose}
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 transition-colors active:scale-90" className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 transition-colors active:scale-90"
aria-label={t('btn.close')} aria-label={t('btn.close')}
> >
<X size={14} strokeWidth={2.5} /> <X size={14} strokeWidth={2.5} />
</button> </button>
<SidebarContent onNav={onMobileClose} /> <SidebarContent onNav={onCompactClose} />
</motion.aside> </motion.aside>
</motion.div> </motion.div>
)} )}
@@ -144,11 +159,12 @@ export function Sidebar({
function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element { function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
const { t } = useT() const { t } = useT()
const running = useAppStore((s) => s.state?.settings.globalEnabled ?? true)
return ( return (
<> <>
<div className="px-5 pt-7 pb-6"> <div className="px-5 pt-7 pb-6">
<div className="font-serif text-[36px] leading-none tracking-tight font-bold"> <div className="font-serif text-[36px] leading-none tracking-tight font-bold">
Laude Разомнись
</div> </div>
<div className="text-[13px] text-text/55 mt-2 font-medium"> <div className="text-[13px] text-text/55 mt-2 font-medium">
{t('sidebar.slogan')} {t('sidebar.slogan')}
@@ -200,10 +216,17 @@ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
<div className="mt-auto px-5 pb-5"> <div className="mt-auto px-5 pb-5">
<div className="flex items-center gap-2 text-[11px] text-text/45"> <div className="flex items-center gap-2 text-[11px] text-text/45">
<span className="relative flex h-1.5 w-1.5"> <span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" /> {running && (
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" /> <span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
)}
<span
className={[
'relative inline-flex rounded-full h-1.5 w-1.5',
running ? 'bg-success' : 'bg-warning'
].join(' ')}
/>
</span> </span>
{t('sidebar.status_tracking')} {running ? t('sidebar.status_tracking') : t('sidebar.status_paused')}
</div> </div>
</div> </div>
</> </>

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

@@ -12,20 +12,22 @@ export type Dict = Record<string, string>
export const ru: Dict = { export const ru: Dict = {
// Sidebar / nav // Sidebar / nav
'nav.today': 'Сегодня', 'nav.today': 'Обзор',
'nav.exercises': 'Упражнения', 'nav.exercises': 'Упражнения',
'nav.meals': 'Питание',
'nav.games': 'Игры', 'nav.games': 'Игры',
'nav.challenges': 'Челленджи', 'nav.challenges': 'Челленджи',
'nav.settings': 'Настройки', 'nav.settings': 'Настройки',
'sidebar.slogan': 'Двигайся осознанно', 'sidebar.slogan': 'Лёгкий перерыв без потери фокуса',
'sidebar.status_tracking': 'Активность отслеживается', 'sidebar.status_tracking': 'Активность отслеживается',
'sidebar.status_paused': 'Напоминания на паузе',
'titlebar.menu_aria': 'Меню', 'titlebar.menu_aria': 'Меню',
'titlebar.minimize_aria': 'Свернуть', 'titlebar.minimize_aria': 'Свернуть',
'titlebar.maximize_aria': 'Развернуть', 'titlebar.maximize_aria': 'Развернуть',
'titlebar.restore_aria': 'Восстановить размер', 'titlebar.restore_aria': 'Восстановить размер',
'titlebar.tray_aria': 'В трей', 'titlebar.tray_aria': 'В трей',
'titlebar.close_aria': 'Закрыть', 'titlebar.close_aria': 'Закрыть',
'titlebar.app_title': 'Exercise Reminder', 'titlebar.app_title': 'Разомнись',
// Common buttons / actions // Common buttons / actions
'btn.add': 'Добавить', 'btn.add': 'Добавить',
@@ -59,11 +61,34 @@ export const ru: Dict = {
'btn.retry': 'Повторить', 'btn.retry': 'Повторить',
// Dashboard // Dashboard
'dashboard.kicker': 'Тренировка дня', 'dashboard.kicker': 'План перерывов',
'dashboard.title': 'Сегодня', 'dashboard.title': 'Что важно сейчас',
'dashboard.header.date': 'План на {date}',
'dashboard.header.status.paused': 'пауза',
'dashboard.header.status.meeting': 'встреча',
'dashboard.header.status.due': 'ждёт действия',
'dashboard.header.status.running': 'в работе',
'dashboard.header.status.clear': 'спокойно',
'dashboard.header.title.paused': 'Напоминания на паузе',
'dashboard.header.subtitle.paused':
'Запусти их снова, когда будешь готов вернуться к коротким перерывам.',
'dashboard.header.title.meeting': 'Встреча активна',
'dashboard.header.subtitle.meeting':
'Пауза на встречах включена. Напоминания продолжатся, когда звонок закончится.',
'dashboard.header.title.due': 'Пора сделать: {name}',
'dashboard.header.subtitle.due':
'{kind} · {meta}. Это ближайшее действие по плану.',
'dashboard.header.title.next': 'Следующее: {name}',
'dashboard.header.subtitle.next': '{kind} · {meta} · {time}',
'dashboard.header.title.empty': 'Настрой первый перерыв',
'dashboard.header.subtitle.empty':
'Добавь упражнение или питание, чтобы приложение собрало понятный план дня.',
'dashboard.header.title.clear': 'План под контролем',
'dashboard.header.subtitle.clear':
'Срочных действий нет. Ниже видно цели, ритм недели и игровые долги.',
'dashboard.stat.active': 'Активных', 'dashboard.stat.active': 'Активных',
'dashboard.stat.active.of': 'из {total}', 'dashboard.stat.active.of': 'из {total}',
'dashboard.stat.today_done': 'Сегодня', 'dashboard.stat.today_done': 'Сделано',
'dashboard.stat.today_done.subtitle': 'повторов за день', 'dashboard.stat.today_done.subtitle': 'повторов за день',
'dashboard.stat.streak': 'Стрик', 'dashboard.stat.streak': 'Стрик',
'dashboard.stat.streak.subtitle': '{n} дн. подряд', 'dashboard.stat.streak.subtitle': '{n} дн. подряд',
@@ -74,27 +99,146 @@ export const ru: Dict = {
'dashboard.stat.tracking': 'Трекинг матчей', 'dashboard.stat.tracking': 'Трекинг матчей',
'dashboard.stat.tracking.on': 'On', 'dashboard.stat.tracking.on': 'On',
'dashboard.stat.tracking.off': 'Off', 'dashboard.stat.tracking.off': 'Off',
'dashboard.stat.tracking.pending': 'Setup', 'dashboard.stat.tracking.pending': 'Настройка',
'dashboard.stat.tracking.subtitle_on': 'в реальном времени', 'dashboard.stat.tracking.subtitle_on': 'в реальном времени',
'dashboard.stat.tracking.subtitle_off': 'выключен', 'dashboard.stat.tracking.subtitle_off': 'выключен',
'dashboard.stat.tracking.subtitle_pending': 'dashboard.stat.tracking.subtitle_pending':
'нужно закрыть Steam и снова открыть', 'нужно закрыть Steam и снова открыть',
'dashboard.paused.title': 'Напоминания на паузе', 'dashboard.paused.title': 'Напоминания на паузе',
'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт', 'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт',
'dashboard.meeting.title': 'Не дёргаем — ты на встрече', 'dashboard.meeting.title': 'Встреча активна',
'dashboard.meeting.hint': 'dashboard.meeting.hint':
'Запущен Zoom / Teams / Discord / Webex / Slack-huddle. Напоминания возобновятся когда закроешь.', 'Запущен Zoom / Teams / Webex / Slack-huddle. Напоминания возобновятся, когда встреча закончится.',
'dashboard.plan.title': 'Ближайший шаг',
'dashboard.plan.subtitle': 'Что сделать сейчас, дневные цели и питание',
'dashboard.plan.due_count': '{n} к выполнению',
'dashboard.plan.all_caught_up': 'всё спокойно',
'dashboard.plan.next_action': 'Следующее действие',
'dashboard.plan.kind.exercise': 'упражнение',
'dashboard.plan.kind.meal': 'питание',
'dashboard.plan.due_now': 'можно сделать сейчас',
'dashboard.plan.next_in': 'через {time}',
'dashboard.plan.paused': 'напоминания на паузе',
'dashboard.plan.meal_time': 'в {time}',
'dashboard.plan.done_now': 'Сделал',
'dashboard.plan.ate_now': 'Поел',
'dashboard.plan.clear.title': 'На сегодня чисто',
'dashboard.plan.clear.hint': 'Можно отдохнуть или добавить новое действие',
'dashboard.plan.goals': 'Дневные цели',
'dashboard.plan.goals.progress': '{done}/{goal}',
'dashboard.plan.goals.remaining': 'осталось {n} раз',
'dashboard.plan.goals.hint': 'прогресс по упражнениям с дневной целью',
'dashboard.plan.goals.empty':
'Добавь дневную цель в упражнении, чтобы видеть прогресс',
'dashboard.plan.meals': 'Питание',
'dashboard.plan.meals.progress': '{done}/{total}',
'dashboard.plan.recovery': 'Режим',
'dashboard.plan.recovery.first.title': 'Первый шаг',
'dashboard.plan.recovery.first.hint': 'Начни с одного лёгкого действия',
'dashboard.plan.recovery.return.title': 'Мягкий возврат',
'dashboard.plan.recovery.return.hint':
'{n} дн. без действий — начни с минимума',
'dashboard.plan.recovery.steady.title': 'Ритм держится',
'dashboard.plan.recovery.steady.today': 'сегодня уже есть действие',
'dashboard.plan.recovery.steady.yesterday': 'вчера был активный день',
'dashboard.plan.recovery.steady.none': 'держим спокойный темп',
'dashboard.plan.up_next': 'Дальше',
'dashboard.plan.item.remaining': 'осталось {n}',
'dashboard.plan.item.remaining_reps': 'осталось {n} раз',
'dashboard.plan.item.reps': '{n} раз',
'dashboard.empty.title': 'Программа пуста', 'dashboard.empty.title': 'Программа пуста',
'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать', 'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать',
// Momentum / today redesign
'momentum.level.title': 'Уровень',
'momentum.level.number': 'уровень {n}',
'momentum.level.next': 'до «{name}» осталось {n} XP',
'momentum.level.max': 'максимальный уровень',
'momentum.level.warmup': 'Разогрелся',
'momentum.level.rhythm': 'Вошёл в ритм',
'momentum.level.steady': 'Держу форму',
'momentum.level.back': 'Железная спина',
'momentum.level.machine': 'Машина привычек',
'momentum.level.legend': 'Легенда перерывов',
'momentum.week.kicker': 'Челленджи недели',
'momentum.week.title': 'Ритм за неделю',
'momentum.week.summary': '{days} дн · {reps} повт',
'momentum.quest.complete': 'закрыто',
'momentum.quest.week_rhythm.title': '5 дней без нуля',
'momentum.quest.week_rhythm.desc': 'отметь активность в 5 разных дней',
'momentum.quest.week_reps.title': '1000 повторов',
'momentum.quest.week_reps.desc': 'набери тысячу повторов за неделю',
'momentum.quest.match_debt.title': 'Закрыть катки',
'momentum.quest.match_debt.desc': 'закрой 3 игровых долга за неделю',
'momentum.quest.today_anchor.title': 'День не ноль',
'momentum.quest.today_anchor.desc': 'сделай хотя бы одно действие сегодня',
'momentum.game.kicker': 'После катки',
'momentum.game.title': 'Игровой долг',
'momentum.game.status': 'Dota GSI',
'momentum.game.live': 'live',
'momentum.game.setup': 'настройка',
'momentum.game.off': 'выкл',
'momentum.game.today': 'сегодня',
'momentum.game.week': 'неделя',
'momentum.game.reps': '{n} повт',
'momentum.game.entries': '{n} закрыто',
'momentum.game.last': 'последняя катка: {date}',
'momentum.game.no_matches': 'закрытых игровых долгов пока нет',
'momentum.game.no_rules':
'Добавь челлендж за матч, и здесь появится долг после каток.',
// Exercises // Exercises
'exercises.kicker': 'Программа', 'exercises.kicker': 'Программа',
'exercises.title': 'Упражнения', 'exercises.title': 'Упражнения',
'exercises.subtitle':
'Собери короткие действия, которые легко сделать между задачами.',
'exercises.insight.active': 'Активно',
'exercises.insight.active.hint': 'Напоминания, которые сейчас работают',
'exercises.insight.load': 'Нагрузка',
'exercises.insight.load.value': '{n} раз',
'exercises.insight.load.hint': 'Сумма повторов за один полный круг',
'exercises.insight.goals': 'Цели',
'exercises.insight.goals.hint': 'Упражнения с дневной нормой',
'exercises.section.active': 'Активные · {n}', 'exercises.section.active': 'Активные · {n}',
'exercises.section.disabled': 'Выключенные · {n}', 'exercises.section.disabled': 'Выключенные · {n}',
'exercises.row.meta': '{reps} раз · {interval}', 'exercises.row.meta': '{reps} раз · {interval}',
'exercises.empty': 'Программа пуста — добавь первое упражнение', 'exercises.empty': 'Программа пуста — добавь первое упражнение',
// Meals (приёмы пищи)
'meals.kicker': 'Режим питания',
'meals.title': 'Питание',
'meals.subtitle': 'Держи еду в расписании, чтобы не выпадать из ритма.',
'meals.insight.active': 'Активно',
'meals.insight.active.hint': 'Приёмы пищи с включённым напоминанием',
'meals.insight.next': 'Следующее',
'meals.insight.next.hint': 'Ближайшее время сегодня',
'meals.insight.presets': 'Пресеты',
'meals.insight.presets.hint': 'Быстрые варианты для старта',
'meals.presets': 'Быстрое добавление',
'meals.schedule': 'Расписание',
'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 // Exercise editor
'editor.exercise.title.new': 'Новое упражнение', 'editor.exercise.title.new': 'Новое упражнение',
'editor.exercise.title.edit': 'Редактировать', 'editor.exercise.title.edit': 'Редактировать',
@@ -111,6 +255,13 @@ export const ru: Dict = {
'challenges.title': 'Челленджи', 'challenges.title': 'Челленджи',
'challenges.subtitle': 'Повторов = {formula}', 'challenges.subtitle': 'Повторов = {formula}',
'challenges.subtitle.formula': 'статистика × коэффициент', 'challenges.subtitle.formula': 'статистика × коэффициент',
'challenges.insight.active': 'Активно',
'challenges.insight.active.hint': 'Правила, которые начисляют долг',
'challenges.insight.games': 'Игры',
'challenges.insight.games.hint': 'Включённые игровые интеграции',
'challenges.insight.debt': 'Тест долга',
'challenges.insight.debt.value': '{n} раз',
'challenges.insight.debt.hint': 'Если каждое правило поймает 5 событий',
'challenges.warning.no_games': 'challenges.warning.no_games':
'Челленджи срабатывают после матча. Подключи игру во вкладке «Игры».', 'Челленджи срабатывают после матча. Подключи игру во вкладке «Игры».',
'challenges.section.all': 'Все · {n}', 'challenges.section.all': 'Все · {n}',
@@ -136,6 +287,13 @@ export const ru: Dict = {
'games.subtitle': 'Подключи игру — челленджи сработают сразу после матча', 'games.subtitle': 'Подключи игру — челленджи сработают сразу после матча',
'games.subtitle.live': '{n} live', 'games.subtitle.live': '{n} live',
'games.section.supported': 'Поддерживаемые', 'games.section.supported': 'Поддерживаемые',
'games.insight.supported': 'Поддержка',
'games.insight.supported.hint': 'Игры, которые умеет отслеживать приложение',
'games.insight.connected': 'Подключено',
'games.insight.connected.hint': 'Интеграции с установленной GSI',
'games.insight.live': 'Сигнал',
'games.insight.live.hint': 'Live или ожидание перезапуска Steam',
'games.insight.queued': '{n} в очереди',
'games.scanning': 'Сканируем установленные игры…', 'games.scanning': 'Сканируем установленные игры…',
'games.queued.body': 'games.queued.body':
'Steam запущен. Параметр {opt} пропишется автоматически при следующем закрытии Steam.', 'Steam запущен. Параметр {opt} пропишется автоматически при следующем закрытии Steam.',
@@ -152,13 +310,38 @@ export const ru: Dict = {
// Settings // Settings
'settings.kicker': 'Конфигурация', 'settings.kicker': 'Конфигурация',
'settings.title': 'Настройки', 'settings.title': 'Настройки',
'settings.insight.mode': 'Уведомления',
'settings.insight.mode.hint': 'Как приложение говорит о перерыве',
'settings.insight.theme': 'Тема',
'settings.insight.theme.hint': 'Визуальный режим интерфейса',
'settings.insight.language': 'Язык',
'settings.insight.language.hint': 'Применяется без перезапуска',
'settings.status.kicker': 'Состояние',
'settings.status.title.on': 'Напоминания работают',
'settings.status.title.off': 'Напоминания остановлены',
'settings.status.hint.paused':
'Перерывы не будут появляться, пока ты снова не запустишь напоминания.',
'settings.status.hint.quiet':
'Напоминания работают, но тихие часы {from}-{to} могут временно их скрывать.',
'settings.status.hint.autostart':
'После перезагрузки приложение не запустится само.',
'settings.status.hint.ready':
'Приложение запустится с Windows и будет мягко вести расписание перерывов.',
'settings.status.reminders': 'Напоминания',
'settings.status.quiet': 'Тихие часы',
'settings.status.meetings': 'Встречи',
'settings.status.autostart': 'Windows',
'settings.status.on': 'Вкл',
'settings.status.off': 'Выкл',
'settings.section.reminders': 'Напоминания', 'settings.section.reminders': 'Напоминания',
'settings.section.quiet': 'Тихие часы', 'settings.section.quiet': 'Тихие часы',
'settings.section.window': 'Окно и трей', 'settings.section.window': 'Окно и трей',
'settings.section.appearance': 'Внешний вид', 'settings.section.appearance': 'Внешний вид',
'settings.section.language': 'Язык', 'settings.section.language': 'Язык',
'settings.section.interface': 'Интерфейс',
'settings.section.updates': 'Обновления', 'settings.section.updates': 'Обновления',
'settings.section.data': 'Данные', 'settings.section.data': 'Данные',
'settings.section.diagnostics': 'Диагностика',
'settings.data.export.label': 'Экспортировать всё', 'settings.data.export.label': 'Экспортировать всё',
'settings.data.export.hint': 'settings.data.export.hint':
'Сохрани резервную копию упражнений, истории, челленджей и настроек в JSON-файл.', 'Сохрани резервную копию упражнений, истории, челленджей и настроек в JSON-файл.',
@@ -173,7 +356,23 @@ export const ru: Dict = {
'Все текущие упражнения, история и настройки будут заменены содержимым файла. Продолжить?', 'Все текущие упражнения, история и настройки будут заменены содержимым файла. Продолжить?',
'settings.data.import.ok': 'Восстановлено', 'settings.data.import.ok': 'Восстановлено',
'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?', 'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?',
'settings.diagnostics.app.label': 'Приложение',
'settings.diagnostics.data.label': 'Данные',
'settings.diagnostics.data.legend': 'упр/еда/чел/ист',
'settings.diagnostics.gsi.label': 'Dota GSI',
'settings.diagnostics.hint':
'Технический снимок без токенов: версии, пути, статусы и счетчики.',
'settings.diagnostics.loading': 'Загружаем…',
'settings.diagnostics.err': 'Не удалось собрать диагностику',
'settings.diagnostics.refresh': 'Обновить диагностику',
'settings.diagnostics.copy.btn': 'Копировать',
'settings.diagnostics.copy.ok': 'Диагностика скопирована',
'settings.diagnostics.logs.btn': 'Логи',
'settings.diagnostics.logs.ok': 'Папка логов открыта',
'settings.diagnostics.logs.err': 'Не удалось открыть папку логов',
'settings.section.about': 'О приложении', 'settings.section.about': 'О приложении',
'settings.version.label': 'Версия',
'settings.version.hint': 'Текущая установленная версия приложения.',
'settings.whatsnew.label': 'Что нового', 'settings.whatsnew.label': 'Что нового',
'settings.whatsnew.hint': 'Посмотреть заметки последних релизов.', 'settings.whatsnew.hint': 'Посмотреть заметки последних релизов.',
'settings.whatsnew.btn': 'Открыть', 'settings.whatsnew.btn': 'Открыть',
@@ -185,6 +384,8 @@ export const ru: Dict = {
'settings.notification_mode.modal': 'Окно поверх всех', 'settings.notification_mode.modal': 'Окно поверх всех',
'settings.notification_mode.toast': 'Системное уведомление', 'settings.notification_mode.toast': 'Системное уведомление',
'settings.notification_mode.both': 'Окно и уведомление', 'settings.notification_mode.both': 'Окно и уведомление',
'settings.global.label': 'Напоминания включены',
'settings.global.hint': 'Главный режим работы приложения',
'settings.sound.label': 'Звук уведомления', 'settings.sound.label': 'Звук уведомления',
'settings.sound.hint': 'Короткий сигнал при срабатывании', 'settings.sound.hint': 'Короткий сигнал при срабатывании',
'settings.voice.label': 'Голосовая подсказка', 'settings.voice.label': 'Голосовая подсказка',
@@ -192,7 +393,7 @@ export const ru: Dict = {
'Диктор произносит название упражнения и количество — полезно когда фокус на коде.', 'Диктор произносит название упражнения и количество — полезно когда фокус на коде.',
'settings.meeting_pause.label': 'Пауза на встречах', 'settings.meeting_pause.label': 'Пауза на встречах',
'settings.meeting_pause.hint': 'settings.meeting_pause.hint':
'Не дёргать, если запущен Zoom / Teams / Discord / Webex / Slack-huddle.', 'Ставить напоминания на паузу, если запущен Zoom / Teams / Webex / Slack-huddle.',
'settings.snooze.label': '«Отложить» на', 'settings.snooze.label': '«Отложить» на',
'settings.snooze.hint': 'Сколько минут добавлять при отложении', 'settings.snooze.hint': 'Сколько минут добавлять при отложении',
'settings.snooze.1': '1 минута', 'settings.snooze.1': '1 минута',
@@ -237,9 +438,11 @@ export const ru: Dict = {
'updater.available.title': 'Доступна v{v}', 'updater.available.title': 'Доступна v{v}',
'updater.downloading.title': 'Загружаем обновление', 'updater.downloading.title': 'Загружаем обновление',
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с', 'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
'updater.downloading.hint': 'Можно закрыть это окно — скачивание продолжится в фоне.', 'updater.downloading.hint':
'Можно закрыть это окно — скачивание продолжится в фоне.',
'updater.downloaded.title': 'Готово · v{v}', 'updater.downloaded.title': 'Готово · v{v}',
'updater.downloaded.subtitle': 'Нажми «Рестарт» — приложение моментально откроется в новой версии.', 'updater.downloaded.subtitle':
'Нажми «Рестарт» — приложение моментально откроется в новой версии.',
'updater.error.title': 'Ошибка проверки', 'updater.error.title': 'Ошибка проверки',
'updater.idle.title': 'Проверить обновления', 'updater.idle.title': 'Проверить обновления',
'updater.idle.subtitle': 'Авто-проверка раз в час', 'updater.idle.subtitle': 'Авто-проверка раз в час',
@@ -248,6 +451,7 @@ export const ru: Dict = {
'achievements.title': 'Достижения', 'achievements.title': 'Достижения',
'achievements.unlocked_of': '{n} из {total}', 'achievements.unlocked_of': '{n} из {total}',
'achievements.progress': 'осталось {n}', 'achievements.progress': 'осталось {n}',
'achievements.announce': 'Достижение получено: {title}',
'achievement.reps.desc': 'Сделай {target} повторений всего', 'achievement.reps.desc': 'Сделай {target} повторений всего',
'achievement.reps_100.title': 'Сотня', 'achievement.reps_100.title': 'Сотня',
'achievement.reps_500.title': 'Пятьсот', 'achievement.reps_500.title': 'Пятьсот',
@@ -342,20 +546,22 @@ export const ru: Dict = {
export const en: Dict = { export const en: Dict = {
// Sidebar / nav // Sidebar / nav
'nav.today': 'Today', 'nav.today': 'Overview',
'nav.exercises': 'Exercises', 'nav.exercises': 'Exercises',
'nav.meals': 'Meals',
'nav.games': 'Games', 'nav.games': 'Games',
'nav.challenges': 'Challenges', 'nav.challenges': 'Challenges',
'nav.settings': 'Settings', 'nav.settings': 'Settings',
'sidebar.slogan': 'Move with intention', 'sidebar.slogan': 'A small break without losing focus',
'sidebar.status_tracking': 'Activity tracking is on', 'sidebar.status_tracking': 'Activity tracking is on',
'sidebar.status_paused': 'Reminders paused',
'titlebar.menu_aria': 'Menu', 'titlebar.menu_aria': 'Menu',
'titlebar.minimize_aria': 'Minimize', 'titlebar.minimize_aria': 'Minimize',
'titlebar.maximize_aria': 'Maximize', 'titlebar.maximize_aria': 'Maximize',
'titlebar.restore_aria': 'Restore size', 'titlebar.restore_aria': 'Restore size',
'titlebar.tray_aria': 'To tray', 'titlebar.tray_aria': 'To tray',
'titlebar.close_aria': 'Close', 'titlebar.close_aria': 'Close',
'titlebar.app_title': 'Exercise Reminder', 'titlebar.app_title': 'Razomnis',
// Common buttons // Common buttons
'btn.add': 'Add', 'btn.add': 'Add',
@@ -389,11 +595,34 @@ export const en: Dict = {
'btn.retry': 'Retry', 'btn.retry': 'Retry',
// Dashboard // Dashboard
'dashboard.kicker': 'Daily training', 'dashboard.kicker': 'Break plan',
'dashboard.title': 'Today', 'dashboard.title': 'What matters now',
'dashboard.header.date': 'Plan for {date}',
'dashboard.header.status.paused': 'paused',
'dashboard.header.status.meeting': 'meeting',
'dashboard.header.status.due': 'action due',
'dashboard.header.status.running': 'running',
'dashboard.header.status.clear': 'clear',
'dashboard.header.title.paused': 'Reminders are paused',
'dashboard.header.subtitle.paused':
'Start them again when you are ready to return to short breaks.',
'dashboard.header.title.meeting': 'Meeting active',
'dashboard.header.subtitle.meeting':
'Meeting pause is enabled. Reminders will continue when the call ends.',
'dashboard.header.title.due': 'Time to do: {name}',
'dashboard.header.subtitle.due':
'{kind} · {meta}. This is the closest action in the plan.',
'dashboard.header.title.next': 'Next: {name}',
'dashboard.header.subtitle.next': '{kind} · {meta} · {time}',
'dashboard.header.title.empty': 'Set up your first break',
'dashboard.header.subtitle.empty':
'Add an exercise or meal so the app can build a clear day plan.',
'dashboard.header.title.clear': 'Plan under control',
'dashboard.header.subtitle.clear':
'No urgent actions. Goals, weekly rhythm and game debts are below.',
'dashboard.stat.active': 'Active', 'dashboard.stat.active': 'Active',
'dashboard.stat.active.of': 'of {total}', 'dashboard.stat.active.of': 'of {total}',
'dashboard.stat.today_done': 'Today', 'dashboard.stat.today_done': 'Done',
'dashboard.stat.today_done.subtitle': 'reps logged', 'dashboard.stat.today_done.subtitle': 'reps logged',
'dashboard.stat.streak': 'Streak', 'dashboard.stat.streak': 'Streak',
'dashboard.stat.streak.subtitle': '{n} days in a row', 'dashboard.stat.streak.subtitle': '{n} days in a row',
@@ -409,21 +638,140 @@ export const en: Dict = {
'dashboard.stat.tracking.subtitle_off': 'disabled', 'dashboard.stat.tracking.subtitle_off': 'disabled',
'dashboard.stat.tracking.subtitle_pending': 'close & reopen Steam', 'dashboard.stat.tracking.subtitle_pending': 'close & reopen Steam',
'dashboard.paused.title': 'Reminders paused', 'dashboard.paused.title': 'Reminders paused',
'dashboard.meeting.title': "You're in a meeting — won't interrupt", 'dashboard.meeting.title': 'Meeting active',
'dashboard.meeting.hint': 'dashboard.meeting.hint':
'Zoom / Teams / Discord / Webex / Slack-huddle is running. Reminders resume when you close it.', 'Zoom / Teams / Webex / Slack-huddle is running. Reminders resume when the meeting ends.',
'dashboard.paused.hint': 'Resume to continue countdown', 'dashboard.paused.hint': 'Resume to continue countdown',
'dashboard.plan.title': 'Closest step',
'dashboard.plan.subtitle': 'What to do now, daily goals and meals',
'dashboard.plan.due_count': '{n} due',
'dashboard.plan.all_caught_up': 'all clear',
'dashboard.plan.next_action': 'Next action',
'dashboard.plan.kind.exercise': 'exercise',
'dashboard.plan.kind.meal': 'meal',
'dashboard.plan.due_now': 'ready now',
'dashboard.plan.next_in': 'in {time}',
'dashboard.plan.paused': 'reminders paused',
'dashboard.plan.meal_time': 'at {time}',
'dashboard.plan.done_now': 'Done',
'dashboard.plan.ate_now': 'Ate',
'dashboard.plan.clear.title': 'Clear for today',
'dashboard.plan.clear.hint': 'Rest or add another action',
'dashboard.plan.goals': 'Daily goals',
'dashboard.plan.goals.progress': '{done}/{goal}',
'dashboard.plan.goals.remaining': '{n} left',
'dashboard.plan.goals.hint': 'progress across exercises with daily goals',
'dashboard.plan.goals.empty':
'Add a daily goal to an exercise to see progress',
'dashboard.plan.meals': 'Meals',
'dashboard.plan.meals.progress': '{done}/{total}',
'dashboard.plan.recovery': 'Mode',
'dashboard.plan.recovery.first.title': 'First step',
'dashboard.plan.recovery.first.hint': 'Start with one easy action',
'dashboard.plan.recovery.return.title': 'Gentle return',
'dashboard.plan.recovery.return.hint':
'{n} days without actions — start small',
'dashboard.plan.recovery.steady.title': 'Rhythm holding',
'dashboard.plan.recovery.steady.today': 'you already logged one today',
'dashboard.plan.recovery.steady.yesterday': 'yesterday stayed active',
'dashboard.plan.recovery.steady.none': 'keep a calm pace',
'dashboard.plan.up_next': 'Up next',
'dashboard.plan.item.remaining': '{n} left',
'dashboard.plan.item.remaining_reps': '{n} reps left',
'dashboard.plan.item.reps': '{n} reps',
'dashboard.empty.title': 'Program is empty', 'dashboard.empty.title': 'Program is empty',
'dashboard.empty.hint': 'Add your first exercise to start', 'dashboard.empty.hint': 'Add your first exercise to start',
// Momentum / today redesign
'momentum.level.title': 'Level',
'momentum.level.number': 'level {n}',
'momentum.level.next': '{n} XP to "{name}"',
'momentum.level.max': 'max level',
'momentum.level.warmup': 'Warmed up',
'momentum.level.rhythm': 'In rhythm',
'momentum.level.steady': 'Keeping shape',
'momentum.level.back': 'Iron back',
'momentum.level.machine': 'Habit machine',
'momentum.level.legend': 'Break legend',
'momentum.week.kicker': 'Weekly challenges',
'momentum.week.title': 'Week rhythm',
'momentum.week.summary': '{days} d · {reps} reps',
'momentum.quest.complete': 'done',
'momentum.quest.week_rhythm.title': '5 non-zero days',
'momentum.quest.week_rhythm.desc': 'log activity on 5 different days',
'momentum.quest.week_reps.title': '1000 reps',
'momentum.quest.week_reps.desc': 'reach one thousand reps this week',
'momentum.quest.match_debt.title': 'Close matches',
'momentum.quest.match_debt.desc': 'close 3 game debts this week',
'momentum.quest.today_anchor.title': 'Non-zero day',
'momentum.quest.today_anchor.desc': 'complete at least one action today',
'momentum.game.kicker': 'After match',
'momentum.game.title': 'Game debt',
'momentum.game.status': 'Dota GSI',
'momentum.game.live': 'live',
'momentum.game.setup': 'setup',
'momentum.game.off': 'off',
'momentum.game.today': 'today',
'momentum.game.week': 'week',
'momentum.game.reps': '{n} reps',
'momentum.game.entries': '{n} closed',
'momentum.game.last': 'last match: {date}',
'momentum.game.no_matches': 'no closed game debts yet',
'momentum.game.no_rules':
'Add a per-match challenge and game debt will show up here.',
// Exercises // Exercises
'exercises.kicker': 'Program', 'exercises.kicker': 'Program',
'exercises.title': 'Exercises', 'exercises.title': 'Exercises',
'exercises.subtitle':
'Build short actions that are easy to do between tasks.',
'exercises.insight.active': 'Active',
'exercises.insight.active.hint': 'Reminders currently running',
'exercises.insight.load': 'Load',
'exercises.insight.load.value': '{n} reps',
'exercises.insight.load.hint': 'Total reps in one full cycle',
'exercises.insight.goals': 'Goals',
'exercises.insight.goals.hint': 'Exercises with a daily target',
'exercises.section.active': 'Active · {n}', 'exercises.section.active': 'Active · {n}',
'exercises.section.disabled': 'Disabled · {n}', 'exercises.section.disabled': 'Disabled · {n}',
'exercises.row.meta': '{reps} reps · {interval}', 'exercises.row.meta': '{reps} reps · {interval}',
'exercises.empty': 'Program is empty — add your first exercise', 'exercises.empty': 'Program is empty — add your first exercise',
// Meals
'meals.kicker': 'Eating schedule',
'meals.title': 'Meals',
'meals.subtitle': 'Keep food on the schedule so the day stays steady.',
'meals.insight.active': 'Active',
'meals.insight.active.hint': 'Meals with enabled reminders',
'meals.insight.next': 'Next',
'meals.insight.next.hint': 'Closest time today',
'meals.insight.presets': 'Presets',
'meals.insight.presets.hint': 'Fast starter options',
'meals.presets': 'Quick add',
'meals.schedule': 'Schedule',
'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 // Exercise editor
'editor.exercise.title.new': 'New exercise', 'editor.exercise.title.new': 'New exercise',
'editor.exercise.title.edit': 'Edit', 'editor.exercise.title.edit': 'Edit',
@@ -440,6 +788,13 @@ export const en: Dict = {
'challenges.title': 'Challenges', 'challenges.title': 'Challenges',
'challenges.subtitle': 'Reps = {formula}', 'challenges.subtitle': 'Reps = {formula}',
'challenges.subtitle.formula': 'stat × multiplier', 'challenges.subtitle.formula': 'stat × multiplier',
'challenges.insight.active': 'Active',
'challenges.insight.active.hint': 'Rules that can add debt',
'challenges.insight.games': 'Games',
'challenges.insight.games.hint': 'Enabled game integrations',
'challenges.insight.debt': 'Debt test',
'challenges.insight.debt.value': '{n} reps',
'challenges.insight.debt.hint': 'If each rule catches 5 events',
'challenges.warning.no_games': 'challenges.warning.no_games':
'Challenges trigger after a match. Connect a game in the Games tab.', 'Challenges trigger after a match. Connect a game in the Games tab.',
'challenges.section.all': 'All · {n}', 'challenges.section.all': 'All · {n}',
@@ -465,6 +820,13 @@ export const en: Dict = {
'games.subtitle': 'Connect a game — challenges fire right after the match', 'games.subtitle': 'Connect a game — challenges fire right after the match',
'games.subtitle.live': '{n} live', 'games.subtitle.live': '{n} live',
'games.section.supported': 'Supported', 'games.section.supported': 'Supported',
'games.insight.supported': 'Support',
'games.insight.supported.hint': 'Games this app knows how to track',
'games.insight.connected': 'Connected',
'games.insight.connected.hint': 'Integrations with installed GSI',
'games.insight.live': 'Signal',
'games.insight.live.hint': 'Live or waiting for Steam restart',
'games.insight.queued': '{n} queued',
'games.scanning': 'Scanning installed games…', 'games.scanning': 'Scanning installed games…',
'games.queued.body': 'games.queued.body':
'Steam is running. The {opt} option will be added automatically next time Steam closes.', 'Steam is running. The {opt} option will be added automatically next time Steam closes.',
@@ -481,13 +843,38 @@ export const en: Dict = {
// Settings // Settings
'settings.kicker': 'Configuration', 'settings.kicker': 'Configuration',
'settings.title': 'Settings', 'settings.title': 'Settings',
'settings.insight.mode': 'Notifications',
'settings.insight.mode.hint': 'How the app talks about a break',
'settings.insight.theme': 'Theme',
'settings.insight.theme.hint': 'Visual interface mode',
'settings.insight.language': 'Language',
'settings.insight.language.hint': 'Applied without restart',
'settings.status.kicker': 'Status',
'settings.status.title.on': 'Reminders are running',
'settings.status.title.off': 'Reminders are paused',
'settings.status.hint.paused':
'Breaks will not appear until reminders are started again.',
'settings.status.hint.quiet':
'Reminders are running, but quiet hours {from}-{to} can hide them temporarily.',
'settings.status.hint.autostart':
'The app will not start automatically after reboot.',
'settings.status.hint.ready':
'The app will start with Windows and keep the break schedule moving.',
'settings.status.reminders': 'Reminders',
'settings.status.quiet': 'Quiet hours',
'settings.status.meetings': 'Meetings',
'settings.status.autostart': 'Windows',
'settings.status.on': 'On',
'settings.status.off': 'Off',
'settings.section.reminders': 'Reminders', 'settings.section.reminders': 'Reminders',
'settings.section.quiet': 'Quiet hours', 'settings.section.quiet': 'Quiet hours',
'settings.section.window': 'Window & tray', 'settings.section.window': 'Window & tray',
'settings.section.appearance': 'Appearance', 'settings.section.appearance': 'Appearance',
'settings.section.language': 'Language', 'settings.section.language': 'Language',
'settings.section.interface': 'Interface',
'settings.section.updates': 'Updates', 'settings.section.updates': 'Updates',
'settings.section.data': 'Data', 'settings.section.data': 'Data',
'settings.section.diagnostics': 'Diagnostics',
'settings.data.export.label': 'Export everything', 'settings.data.export.label': 'Export everything',
'settings.data.export.hint': 'settings.data.export.hint':
'Save a backup of exercises, history, challenges and settings to a JSON file.', 'Save a backup of exercises, history, challenges and settings to a JSON file.',
@@ -502,7 +889,23 @@ export const en: Dict = {
'All current exercises, history and settings will be replaced with the file contents. Continue?', 'All current exercises, history and settings will be replaced with the file contents. Continue?',
'settings.data.import.ok': 'Restored', 'settings.data.import.ok': 'Restored',
'settings.data.import.err': "Couldn't read the file — not our backup?", 'settings.data.import.err': "Couldn't read the file — not our backup?",
'settings.diagnostics.app.label': 'Application',
'settings.diagnostics.data.label': 'Data',
'settings.diagnostics.data.legend': 'ex/meal/ch/hist',
'settings.diagnostics.gsi.label': 'Dota GSI',
'settings.diagnostics.hint':
'Technical snapshot without tokens: versions, paths, statuses and counts.',
'settings.diagnostics.loading': 'Loading…',
'settings.diagnostics.err': 'Could not collect diagnostics',
'settings.diagnostics.refresh': 'Refresh diagnostics',
'settings.diagnostics.copy.btn': 'Copy',
'settings.diagnostics.copy.ok': 'Diagnostics copied',
'settings.diagnostics.logs.btn': 'Logs',
'settings.diagnostics.logs.ok': 'Logs folder opened',
'settings.diagnostics.logs.err': 'Could not open logs folder',
'settings.section.about': 'About', 'settings.section.about': 'About',
'settings.version.label': 'Version',
'settings.version.hint': 'Currently installed app version.',
'settings.whatsnew.label': "What's new", 'settings.whatsnew.label': "What's new",
'settings.whatsnew.hint': 'See the latest release notes.', 'settings.whatsnew.hint': 'See the latest release notes.',
'settings.whatsnew.btn': 'Open', 'settings.whatsnew.btn': 'Open',
@@ -514,6 +917,8 @@ export const en: Dict = {
'settings.notification_mode.modal': 'Window on top', 'settings.notification_mode.modal': 'Window on top',
'settings.notification_mode.toast': 'System notification', 'settings.notification_mode.toast': 'System notification',
'settings.notification_mode.both': 'Window and notification', 'settings.notification_mode.both': 'Window and notification',
'settings.global.label': 'Reminders enabled',
'settings.global.hint': 'Main operating mode for the app',
'settings.sound.label': 'Notification sound', 'settings.sound.label': 'Notification sound',
'settings.sound.hint': 'Short beep on trigger', 'settings.sound.hint': 'Short beep on trigger',
'settings.voice.label': 'Voice prompt', 'settings.voice.label': 'Voice prompt',
@@ -521,7 +926,7 @@ export const en: Dict = {
'Speaks the exercise name and count — useful when your eyes are on the code.', 'Speaks the exercise name and count — useful when your eyes are on the code.',
'settings.meeting_pause.label': 'Pause during meetings', 'settings.meeting_pause.label': 'Pause during meetings',
'settings.meeting_pause.hint': 'settings.meeting_pause.hint':
'Skip reminders when Zoom / Teams / Discord / Webex / Slack-huddle is running.', 'Pause reminders when Zoom / Teams / Webex / Slack-huddle is running.',
'settings.snooze.label': '“Snooze” for', 'settings.snooze.label': '“Snooze” for',
'settings.snooze.hint': 'How many minutes to postpone', 'settings.snooze.hint': 'How many minutes to postpone',
'settings.snooze.1': '1 minute', 'settings.snooze.1': '1 minute',
@@ -566,9 +971,11 @@ export const en: Dict = {
'updater.available.title': 'v{v} available', 'updater.available.title': 'v{v} available',
'updater.downloading.title': 'Downloading update', 'updater.downloading.title': 'Downloading update',
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s', 'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s',
'updater.downloading.hint': 'You can close this window — download continues in the background.', 'updater.downloading.hint':
'You can close this window — download continues in the background.',
'updater.downloaded.title': 'Ready · v{v}', 'updater.downloaded.title': 'Ready · v{v}',
'updater.downloaded.subtitle': 'Click Restart — the app will reopen instantly in the new version.', 'updater.downloaded.subtitle':
'Click Restart — the app will reopen instantly in the new version.',
'updater.error.title': 'Check failed', 'updater.error.title': 'Check failed',
'updater.idle.title': 'Check for updates', 'updater.idle.title': 'Check for updates',
'updater.idle.subtitle': 'Auto-check every hour', 'updater.idle.subtitle': 'Auto-check every hour',
@@ -577,6 +984,7 @@ export const en: Dict = {
'achievements.title': 'Achievements', 'achievements.title': 'Achievements',
'achievements.unlocked_of': '{n} of {total}', 'achievements.unlocked_of': '{n} of {total}',
'achievements.progress': '{n} to go', 'achievements.progress': '{n} to go',
'achievements.announce': 'Achievement unlocked: {title}',
'achievement.reps.desc': '{target} reps total', 'achievement.reps.desc': '{target} reps total',
'achievement.reps_100.title': 'Century', 'achievement.reps_100.title': 'Century',
'achievement.reps_500.title': 'Five hundred', '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

@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest'
import type { Exercise, HistoryEntry, Meal } from '@shared/types'
import { computeTodayPlan } from './day-plan'
const NOW = new Date(2026, 5, 6, 12, 0, 0, 0).getTime()
const HOUR = 60 * 60 * 1000
const DAY = 24 * HOUR
function exercise(partial: Partial<Exercise> & { id: string }): Exercise {
return {
id: partial.id,
name: partial.name ?? partial.id,
reps: partial.reps ?? 10,
icon: partial.icon ?? 'Activity',
intervalMinutes: partial.intervalMinutes ?? 30,
enabled: partial.enabled ?? true,
nextFireAt: partial.nextFireAt ?? NOW + HOUR,
category: partial.category,
dailyGoal: partial.dailyGoal,
adaptive: partial.adaptive
}
}
function meal(partial: Partial<Meal> & { id: string }): Meal {
return {
id: partial.id,
name: partial.name ?? partial.id,
time: partial.time ?? '13:00',
icon: partial.icon ?? 'UtensilsCrossed',
enabled: partial.enabled ?? true,
days: partial.days ?? [],
nextFireAt: partial.nextFireAt ?? NOW + HOUR
}
}
function done(
exerciseId: string,
ts = NOW,
reps?: number,
actualReps?: number
): HistoryEntry {
const entry: HistoryEntry = { exerciseId, ts, action: 'done' }
if (reps !== undefined) entry.reps = reps
if (actualReps !== undefined) entry.actualReps = actualReps
return entry
}
describe('computeTodayPlan', () => {
it('summarises daily goals and puts due exercises first', () => {
const plan = computeTodayPlan({
now: NOW,
exercises: [
exercise({ id: 'pushups', dailyGoal: 30, nextFireAt: NOW - 1 }),
exercise({ id: 'water', dailyGoal: 2, reps: 1, nextFireAt: NOW + HOUR })
],
meals: [meal({ id: 'lunch', nextFireAt: NOW + 30 * 60_000 })],
history: [done('pushups', NOW - HOUR, 10)]
})
expect(plan.goalDone).toBe(10)
expect(plan.goalTarget).toBe(32)
expect(plan.goalRemaining).toBe(22)
expect(plan.dueCount).toBe(1)
expect(plan.nextItem?.id).toBe('pushups')
expect(plan.nextItem?.due).toBe(true)
})
it('removes completed daily goals from the action list', () => {
const plan = computeTodayPlan({
now: NOW,
exercises: [
exercise({ id: 'squats', dailyGoal: 20, nextFireAt: NOW - HOUR })
],
meals: [],
history: [done('squats', NOW - HOUR, 20)]
})
expect(plan.goalRemaining).toBe(0)
expect(plan.items).toHaveLength(0)
expect(plan.nextItem).toBeUndefined()
})
it('tracks meal completion via meal history entries', () => {
const plan = computeTodayPlan({
now: NOW,
exercises: [],
meals: [
meal({ id: 'breakfast', nextFireAt: NOW - HOUR }),
meal({ id: 'lunch', nextFireAt: NOW + HOUR })
],
history: [done('meal:breakfast', NOW - 2 * HOUR, 1)]
})
expect(plan.enabledMeals).toBe(2)
expect(plan.doneMeals).toBe(1)
expect(plan.remainingMeals).toBe(1)
expect(plan.items.map((item) => item.id)).toEqual(['lunch'])
})
it('enters recovery mode after two inactive days', () => {
const plan = computeTodayPlan({
now: NOW,
exercises: [exercise({ id: 'a' })],
meals: [],
history: [done('a', NOW - 3 * DAY)]
})
expect(plan.recovery).toEqual({ kind: 'recovery', daysSinceDone: 3 })
})
it('uses first-run state when no done history exists', () => {
const plan = computeTodayPlan({
now: NOW,
exercises: [exercise({ id: 'a' })],
meals: [],
history: []
})
expect(plan.recovery).toEqual({ kind: 'first-run' })
})
it('ignores disabled exercises and meals', () => {
const plan = computeTodayPlan({
now: NOW,
exercises: [exercise({ id: 'a', enabled: false, dailyGoal: 100 })],
meals: [meal({ id: 'm', enabled: false })],
history: []
})
expect(plan.enabledExercises).toBe(0)
expect(plan.enabledMeals).toBe(0)
expect(plan.goalTarget).toBe(0)
expect(plan.items).toHaveLength(0)
})
})

View File

@@ -0,0 +1,187 @@
import type {
Exercise,
HistoryEntry,
Meal,
ReminderCategory
} from '@shared/types'
import { dayKey } from './history'
export type PlanItemKind = 'exercise' | 'meal'
export type PlanItem = {
kind: PlanItemKind
id: string
name: string
icon: string
nextFireAt: number
due: boolean
doneToday: boolean
category?: ReminderCategory
reps?: number
goal?: number
doneReps?: number
remainingReps?: number
time?: string
}
export type RecoveryState =
| { kind: 'first-run' }
| { kind: 'recovery'; daysSinceDone: number }
| { kind: 'steady'; daysSinceDone: number | null }
export type TodayPlan = {
goalDone: number
goalTarget: number
goalRemaining: number
enabledExercises: number
enabledMeals: number
doneMeals: number
remainingMeals: number
dueCount: number
items: PlanItem[]
nextItem?: PlanItem
recovery: RecoveryState
}
function localDayOrdinal(ts: number): number {
const d = new Date(ts)
return Math.floor(
Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000
)
}
function doneRepsForExercise(
entries: HistoryEntry[],
exercise: Exercise,
today: string
): number {
let sum = 0
for (const e of entries) {
if (e.action !== 'done') continue
if (e.exerciseId !== exercise.id) continue
if (dayKey(e.ts) !== today) continue
sum += e.actualReps ?? e.reps ?? exercise.reps
}
return sum
}
function mealDoneToday(
entries: HistoryEntry[],
meal: Meal,
today: string
): boolean {
const id = `meal:${meal.id}`
return entries.some(
(e) => e.action === 'done' && e.exerciseId === id && dayKey(e.ts) === today
)
}
function computeRecovery(entries: HistoryEntry[], now: number): RecoveryState {
const latestDone = entries
.filter((e) => e.action === 'done' && e.ts <= now)
.reduce<number | null>((latest, e) => {
if (latest === null) return e.ts
return e.ts > latest ? e.ts : latest
}, null)
if (latestDone === null) return { kind: 'first-run' }
const daysSinceDone = Math.max(
0,
localDayOrdinal(now) - localDayOrdinal(latestDone)
)
return daysSinceDone >= 2
? { kind: 'recovery', daysSinceDone }
: { kind: 'steady', daysSinceDone }
}
function sortPlanItems(a: PlanItem, b: PlanItem): number {
if (a.due !== b.due) return a.due ? -1 : 1
if (a.nextFireAt !== b.nextFireAt) return a.nextFireAt - b.nextFireAt
if (a.kind !== b.kind) return a.kind === 'exercise' ? -1 : 1
return a.name.localeCompare(b.name)
}
export function computeTodayPlan({
exercises,
meals,
history,
now = Date.now()
}: {
exercises: Exercise[]
meals: Meal[]
history: HistoryEntry[]
now?: number
}): TodayPlan {
const today = dayKey(now)
const enabledExercises = exercises.filter((e) => e.enabled)
const enabledMeals = meals.filter((m) => m.enabled)
let goalDone = 0
let goalTarget = 0
const exerciseItems = enabledExercises
.map<PlanItem>((exercise) => {
const doneReps = doneRepsForExercise(history, exercise, today)
const goal =
exercise.dailyGoal !== undefined && exercise.dailyGoal > 0
? exercise.dailyGoal
: undefined
const remainingReps =
goal !== undefined ? Math.max(0, goal - doneReps) : undefined
const complete = goal !== undefined && remainingReps === 0
if (goal !== undefined) {
goalTarget += goal
goalDone += Math.min(doneReps, goal)
}
return {
kind: 'exercise',
id: exercise.id,
name: exercise.name,
icon: exercise.icon,
category: exercise.category ?? 'exercise',
reps: exercise.reps,
goal,
doneReps,
remainingReps,
doneToday: doneReps > 0,
due: !complete && exercise.nextFireAt <= now,
nextFireAt: exercise.nextFireAt
}
})
.filter((item) => item.remainingReps !== 0)
const mealItems = enabledMeals
.map<PlanItem>((meal) => {
const doneToday = mealDoneToday(history, meal, today)
return {
kind: 'meal',
id: meal.id,
name: meal.name,
icon: meal.icon,
time: meal.time,
doneToday,
due: !doneToday && meal.nextFireAt <= now,
nextFireAt: meal.nextFireAt
}
})
.filter((item) => !item.doneToday)
const items = [...exerciseItems, ...mealItems].sort(sortPlanItems)
return {
goalDone,
goalTarget,
goalRemaining: Math.max(0, goalTarget - goalDone),
enabledExercises: enabledExercises.length,
enabledMeals: enabledMeals.length,
doneMeals: enabledMeals.length - mealItems.length,
remainingMeals: mealItems.length,
dueCount: items.filter((item) => item.due).length,
items,
nextItem: items[0],
recovery: computeRecovery(history, now)
}
}

View File

@@ -0,0 +1,597 @@
import {
DEFAULT_SETTINGS,
nextMealOccurrence,
type AppState,
type Challenge,
type DiagnosticsInfo,
type Exercise,
type GameId,
type GameStatus,
type HistoryEntry,
type Meal,
type RendererErrorReport,
type Settings,
type Tick,
type UpdaterStatus
} from '@shared/types'
type Api = Window['api']
type Handler<T> = (payload: T) => void
const now = Date.now()
let state: AppState = {
exercises: [
{
id: 'dev-ex-squats',
name: 'Приседания',
reps: 10,
icon: 'Activity',
intervalMinutes: 30,
enabled: true,
nextFireAt: now - 90_000,
lastDoneAt: now - 2 * 60 * 60 * 1000,
category: 'exercise',
dailyGoal: 40,
adaptive: true
},
{
id: 'dev-ex-eyes',
name: 'Отдых глазам 20-20-20',
reps: 1,
icon: 'Eye',
intervalMinutes: 20,
enabled: true,
nextFireAt: now + 9 * 60_000,
category: 'eyes'
},
{
id: 'dev-ex-water',
name: 'Стакан воды',
reps: 1,
icon: 'GlassWater',
intervalMinutes: 60,
enabled: true,
nextFireAt: now + 26 * 60_000,
category: 'hydration',
dailyGoal: 6
},
{
id: 'dev-ex-posture',
name: 'Проверь осанку',
reps: 1,
icon: 'PersonStanding',
intervalMinutes: 25,
enabled: false,
nextFireAt: now + 25 * 60_000,
category: 'posture'
}
],
meals: [
{
id: 'dev-meal-breakfast',
name: 'Завтрак',
time: '08:00',
icon: 'Coffee',
enabled: true,
days: [],
nextFireAt: nextMealOccurrence('08:00', [], now),
lastDoneAt: now - 5 * 60 * 60 * 1000
},
{
id: 'dev-meal-lunch',
name: 'Обед',
time: '13:00',
icon: 'UtensilsCrossed',
enabled: true,
days: [],
nextFireAt: nextMealOccurrence('13:00', [], now)
},
{
id: 'dev-meal-dinner',
name: 'Ужин',
time: '19:00',
icon: 'Soup',
enabled: false,
days: [],
nextFireAt: nextMealOccurrence('19:00', [], now)
}
],
settings: {
...DEFAULT_SETTINGS,
lastSeenVersion: '0.6.5'
},
challenges: [
{
id: 'dev-ch-deaths',
name: 'За смерти в Dota',
gameId: 'dota2',
stat: 'deaths',
multiplier: 3,
exerciseName: 'Приседания',
icon: 'Activity',
enabled: true
},
{
id: 'dev-ch-kills',
name: 'За убийства',
gameId: 'dota2',
stat: 'kills',
multiplier: 1,
exerciseName: 'Отжимания',
icon: 'Dumbbell',
enabled: false
}
],
gamesEnabled: { dota2: true }
}
let history: HistoryEntry[] = [
{
ts: now - 2 * 60 * 60 * 1000,
exerciseId: 'dev-ex-squats',
action: 'done',
reps: 10,
name: 'Приседания',
source: 'reminder'
},
{
ts: now - 5 * 60 * 60 * 1000,
exerciseId: 'meal:dev-meal-breakfast',
action: 'done',
reps: 1,
name: 'Завтрак',
source: 'meal'
},
{
ts: now - 26 * 60 * 60 * 1000,
exerciseId: 'dev-ex-eyes',
action: 'done',
reps: 1,
name: 'Отдых глазам 20-20-20',
source: 'reminder'
},
{
ts: now - 48 * 60 * 60 * 1000,
exerciseId: 'dev-ex-squats',
action: 'done',
reps: 10,
name: 'Приседания',
source: 'reminder'
}
]
let games: GameStatus[] = [
{
id: 'dota2',
name: 'Dota 2',
installed: true,
installPath:
'C:\\Program Files (x86)\\Steam\\steamapps\\common\\dota 2 beta',
integrationActive: false,
launchOption: '-gamestateintegration',
launchOptionStatus: 'queued',
steamRunning: true,
enabled: true
}
]
let updaterStatus: UpdaterStatus = {
kind: 'not-available',
currentVersion: '0.6.5',
lastCheckedAt: now - 12 * 60_000
}
const stateHandlers = new Set<Handler<AppState>>()
const tickHandlers = new Set<Handler<Tick[]>>()
const historyHandlers = new Set<Handler<void>>()
const gamesHandlers = new Set<Handler<GameStatus[]>>()
const updaterHandlers = new Set<Handler<UpdaterStatus>>()
const themeHandlers = new Set<Handler<'light' | 'dark'>>()
const emptyUnsub = (): void => undefined
let tickTimer: number | undefined
function cloneState(): AppState {
return structuredClone(state)
}
function emitState(): void {
const snapshot = cloneState()
stateHandlers.forEach((handler) => handler(snapshot))
}
function emitHistory(): void {
historyHandlers.forEach((handler) => handler())
}
function emitGames(): void {
const snapshot = structuredClone(games)
gamesHandlers.forEach((handler) => handler(snapshot))
}
function emitUpdater(): void {
updaterHandlers.forEach((handler) => handler(updaterStatus))
}
function pushHistory(entry: HistoryEntry): void {
history = [entry, ...history]
emitHistory()
}
function findExercise(id: string): Exercise {
const exercise = state.exercises.find((item) => item.id === id)
if (!exercise) throw new Error(`Unknown exercise ${id}`)
return exercise
}
function findMeal(id: string): Meal {
const meal = state.meals.find((item) => item.id === id)
if (!meal) throw new Error(`Unknown meal ${id}`)
return meal
}
function nextId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2, 9)}`
}
function subscribe<T>(set: Set<Handler<T>>, handler: Handler<T>): () => void {
set.add(handler)
return () => set.delete(handler)
}
function buildTicks(): Tick[] {
return state.exercises.map((exercise) => ({
exerciseId: exercise.id,
enabled: exercise.enabled,
msUntilFire: exercise.nextFireAt - Date.now()
}))
}
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (tickTimer !== undefined) window.clearInterval(tickTimer)
})
}
export function installDevApi(): void {
if (window.api || !import.meta.env.DEV) return
const api: Api = {
getState: async () => cloneState(),
addExercise: async (input) => {
const exercise: Exercise = {
...input,
id: nextId('dev-ex'),
nextFireAt: Date.now() + input.intervalMinutes * 60_000
}
state = { ...state, exercises: [...state.exercises, exercise] }
emitState()
return structuredClone(exercise)
},
updateExercise: async (id, patch) => {
let updated = findExercise(id)
state = {
...state,
exercises: state.exercises.map((exercise) => {
if (exercise.id !== id) return exercise
updated = { ...exercise, ...patch, id }
return updated
})
}
emitState()
return structuredClone(updated)
},
deleteExercise: async (id) => {
const before = state.exercises.length
state = {
...state,
exercises: state.exercises.filter((exercise) => exercise.id !== id)
}
emitState()
return state.exercises.length !== before
},
toggleExercise: async (id, enabled) => {
return api.updateExercise(id, { enabled })
},
markDone: async (id, actualReps) => {
const exercise = findExercise(id)
const updated = await api.updateExercise(id, {
lastDoneAt: Date.now(),
nextFireAt: Date.now() + exercise.intervalMinutes * 60_000
})
pushHistory({
ts: Date.now(),
exerciseId: id,
action: 'done',
actualReps,
reps: exercise.reps,
name: exercise.name,
source: 'reminder'
})
return updated
},
snooze: async (id, minutes) => {
return api.updateExercise(id, {
nextFireAt: Date.now() + minutes * 60_000
})
},
skip: async (id) => {
const exercise = findExercise(id)
const updated = await api.updateExercise(id, {
nextFireAt: Date.now() + exercise.intervalMinutes * 60_000
})
pushHistory({
ts: Date.now(),
exerciseId: id,
action: 'skip',
reps: exercise.reps,
name: exercise.name,
source: 'reminder'
})
return updated
},
addMeal: async (input) => {
const meal: Meal = {
...input,
id: nextId('dev-meal'),
nextFireAt: nextMealOccurrence(input.time, input.days, Date.now())
}
state = { ...state, meals: [...state.meals, meal] }
emitState()
return structuredClone(meal)
},
updateMeal: async (id, patch) => {
let updated = findMeal(id)
state = {
...state,
meals: state.meals.map((meal) => {
if (meal.id !== id) return meal
updated = { ...meal, ...patch, id }
if (
(patch.time !== undefined ||
patch.days !== undefined ||
patch.enabled !== undefined) &&
patch.nextFireAt === undefined
) {
updated.nextFireAt = nextMealOccurrence(
updated.time,
updated.days,
Date.now()
)
}
return updated
})
}
emitState()
return structuredClone(updated)
},
deleteMeal: async (id) => {
const before = state.meals.length
state = { ...state, meals: state.meals.filter((meal) => meal.id !== id) }
emitState()
return state.meals.length !== before
},
toggleMeal: async (id, enabled) => api.updateMeal(id, { enabled }),
markMealDone: async (id) => {
const meal = findMeal(id)
const updated = await api.updateMeal(id, {
lastDoneAt: Date.now(),
nextFireAt: nextMealOccurrence(meal.time, meal.days, Date.now())
})
pushHistory({
ts: Date.now(),
exerciseId: `meal:${id}`,
action: 'done',
reps: 1,
name: meal.name,
source: 'meal'
})
return updated
},
updateSettings: async (patch: Partial<Settings>) => {
state = { ...state, settings: { ...state.settings, ...patch } }
if (patch.theme === 'light' || patch.theme === 'dark') {
themeHandlers.forEach((handler) =>
handler(patch.theme as 'light' | 'dark')
)
}
emitState()
return structuredClone(state.settings)
},
getAccentColor: async () => '#ff6b35',
getOsTheme: async () => 'light',
getAppVersion: async () => '0.6.5',
getMeetingActive: async () => false,
getDiagnostics: async () => diagnostics(),
openLogsFolder: async () => ({ ok: true }),
copyDiagnostics: async () => diagnostics(),
reportRendererError: async (report: RendererErrorReport) => {
console.warn('[dev-api] renderer error', report)
return true
},
pauseAll: async () => {
await api.updateSettings({ globalEnabled: false })
},
resumeAll: async () => {
await api.updateSettings({ globalEnabled: true })
},
quit: async () => undefined,
reminderClose: async () => undefined,
minimizeMain: () => undefined,
toggleMaximizeMain: () => undefined,
isMaximizedMain: async () => false,
closeMain: () => undefined,
hideMain: () => undefined,
listGames: async () => structuredClone(games),
installGame: async (id: GameId) => {
games = games.map((game) =>
game.id === id
? {
...game,
enabled: true,
integrationActive: true,
launchOptionStatus: 'applied'
}
: game
)
emitGames()
return structuredClone(games.find((game) => game.id === id)!)
},
uninstallGame: async (id: GameId) => {
games = games.map((game) =>
game.id === id
? { ...game, enabled: false, integrationActive: false }
: game
)
emitGames()
return structuredClone(games.find((game) => game.id === id)!)
},
toggleGame: async (id, enabled) => {
games = games.map((game) =>
game.id === id ? { ...game, enabled } : game
)
state = {
...state,
gamesEnabled: { ...state.gamesEnabled, [id]: enabled }
}
emitGames()
emitState()
},
openGameLaunchOptions: async () => undefined,
addChallenge: async (input) => {
const challenge: Challenge = { ...input, id: nextId('dev-ch') }
state = { ...state, challenges: [...state.challenges, challenge] }
emitState()
return structuredClone(challenge)
},
updateChallenge: async (id, patch) => {
let updated = state.challenges.find((challenge) => challenge.id === id)
if (!updated) throw new Error(`Unknown challenge ${id}`)
state = {
...state,
challenges: state.challenges.map((challenge) => {
if (challenge.id !== id) return challenge
updated = { ...challenge, ...patch, id }
return updated
})
}
emitState()
return structuredClone(updated)
},
deleteChallenge: async (id) => {
const before = state.challenges.length
state = {
...state,
challenges: state.challenges.filter((challenge) => challenge.id !== id)
}
emitState()
return state.challenges.length !== before
},
toggleChallenge: async (id, enabled) => {
return api.updateChallenge(id, { enabled })
},
markChallengeDone: async (id, reps) => {
const challenge = state.challenges.find((item) => item.id === id)
pushHistory({
ts: Date.now(),
exerciseId: `challenge:${id}`,
action: 'done',
actualReps: reps,
reps,
name: challenge?.exerciseName ?? challenge?.name,
source: 'match'
})
return true
},
closeMatchSummary: async () => undefined,
simulateMatchEnd: async () => undefined,
updaterStatus: async () => updaterStatus,
updaterCheck: async () => {
updaterStatus = {
kind: 'not-available',
currentVersion: '0.6.5',
lastCheckedAt: Date.now()
}
emitUpdater()
return updaterStatus
},
updaterDownload: () => undefined,
updaterInstall: () => undefined,
getHistory: async (sinceMs) =>
structuredClone(
sinceMs === undefined
? history
: history.filter((entry) => entry.ts >= sinceMs)
),
clearHistory: async (beforeTs) => {
const before = history.length
history =
beforeTs === undefined
? history
: history.filter((entry) => entry.ts >= beforeTs)
emitHistory()
return before - history.length
},
exportState: async () => ({
ok: true,
canceled: false,
path: 'C:\\Users\\Demo\\Desktop\\razomnis-backup.json'
}),
importState: async () => ({ ok: true, canceled: false }),
onTick: (handler) => subscribe(tickHandlers, handler),
onFire: () => emptyUnsub,
onFireMeal: () => emptyUnsub,
onMatchEnd: () => emptyUnsub,
onStateChanged: (handler) => subscribe(stateHandlers, handler),
onThemeChanged: (handler) => subscribe(themeHandlers, handler),
onAccentChanged: () => emptyUnsub,
onGamesChanged: (handler) => subscribe(gamesHandlers, handler),
onUpdaterStatus: (handler) => subscribe(updaterHandlers, handler),
onMaximizeChanged: () => emptyUnsub,
onMeetingChanged: () => emptyUnsub,
onHistoryChanged: (handler) => subscribe(historyHandlers, handler)
}
window.api = api
tickTimer = window.setInterval(() => {
const ticks = buildTicks()
tickHandlers.forEach((handler) => handler(ticks))
}, 1000)
}
function diagnostics(): DiagnosticsInfo {
return {
generatedAt: Date.now(),
app: {
version: '0.6.5',
isPackaged: false,
platform: 'win32',
arch: 'x64'
},
runtime: {
electron: 'dev',
chrome: 'dev',
node: 'dev'
},
paths: {
userData: 'dev-renderer',
store: 'dev-renderer',
logs: 'dev-renderer'
},
store: {
bytes: null,
exercises: state.exercises.length,
meals: state.meals.length,
challenges: state.challenges.length,
history: history.length
},
updater: updaterStatus,
games,
gsi: {
running: games.some((game) => game.integrationActive),
port: 38087,
baseUrl: 'http://127.0.0.1:38087'
},
meetingActive: false
}
}

View File

@@ -5,7 +5,8 @@ import {
dailyReps, dailyReps,
dayKey, dayKey,
dailyRepsRange, dailyRepsRange,
plannedRepsToday plannedRepsToday,
repsDoneTodayForExercise
} from './history' } from './history'
const MS_DAY = 24 * 60 * 60 * 1000 const MS_DAY = 24 * 60 * 60 * 1000
@@ -197,3 +198,105 @@ describe('currentStreak edge cases', () => {
expect(currentStreak(hist)).toBe(1) 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 { describe, expect, it } from 'vitest'
import { ICON_CHOICES } from './icon-choices' 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', () => { describe('ICON_CHOICES', () => {
// Если иконка SAMPLE_EXERCISES не входит в whitelist, при первом запуске // Если иконка 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', () => { it('has no duplicates', () => {
expect(new Set(ICON_CHOICES).size).toBe(ICON_CHOICES.length) expect(new Set(ICON_CHOICES).size).toBe(ICON_CHOICES.length)
}) })

View File

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

View File

@@ -19,7 +19,9 @@ import {
Apple, Apple,
GlassWater, GlassWater,
BookOpen, BookOpen,
Sparkles Sparkles,
UtensilsCrossed,
Soup
} from 'lucide-react' } from 'lucide-react'
import type { LucideProps } from 'lucide-react' import type { LucideProps } from 'lucide-react'
import { ICON_CHOICES, type IconName } from './icon-choices' import { ICON_CHOICES, type IconName } from './icon-choices'
@@ -44,7 +46,9 @@ const ICON_MAP: Record<IconName, React.ComponentType<LucideProps>> = {
Apple, Apple,
GlassWater, GlassWater,
BookOpen, BookOpen,
Sparkles Sparkles,
UtensilsCrossed,
Soup
} }
/** /**

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import type { Challenge, Exercise, HistoryEntry } from '@shared/types'
import { computeMomentumSummary } from './momentum'
const NOW = new Date(2026, 5, 10, 12, 0, 0, 0).getTime()
const DAY = 24 * 60 * 60 * 1000
function exercise(partial: Partial<Exercise> & { id: string }): Exercise {
return {
id: partial.id,
name: partial.name ?? partial.id,
reps: partial.reps ?? 10,
icon: partial.icon ?? 'Activity',
intervalMinutes: partial.intervalMinutes ?? 30,
enabled: partial.enabled ?? true,
nextFireAt: partial.nextFireAt ?? NOW + 60_000,
category: partial.category,
dailyGoal: partial.dailyGoal,
adaptive: partial.adaptive
}
}
function challenge(partial: Partial<Challenge> & { id: string }): Challenge {
return {
id: partial.id,
name: partial.name ?? partial.id,
gameId: 'dota2',
stat: partial.stat ?? 'deaths',
multiplier: partial.multiplier ?? 3,
exerciseName: partial.exerciseName ?? 'Squats',
icon: partial.icon ?? 'Dumbbell',
enabled: partial.enabled ?? true
}
}
function done(
exerciseId: string,
daysAgo: number,
reps: number,
source?: HistoryEntry['source']
): HistoryEntry {
return {
exerciseId,
ts: NOW - daysAgo * DAY,
action: 'done',
reps,
source
}
}
describe('computeMomentumSummary', () => {
it('tracks weekly quests and match debt from history', () => {
const summary = computeMomentumSummary({
now: NOW,
exercises: [exercise({ id: 'pushups', reps: 15 })],
challenges: [challenge({ id: 'c1' })],
history: [
done('pushups', 0, 15),
done('pushups', 1, 20),
done('pushups', 2, 25),
done('challenge:c1', 0, 30, 'match'),
done('challenge:c1', 2, 45, 'match')
]
})
expect(summary.todayReps).toBe(45)
expect(summary.weekReps).toBe(135)
expect(summary.weekActiveDays).toBe(3)
expect(summary.gameDebt.matchEntriesToday).toBe(1)
expect(summary.gameDebt.matchEntriesWeek).toBe(2)
expect(summary.gameDebt.matchRepsWeek).toBe(75)
expect(summary.weeklyQuests.map((quest) => quest.id)).toEqual([
'week_rhythm',
'week_reps',
'match_debt',
'today_anchor'
])
})
it('hides match quest when there are no enabled challenge rules', () => {
const summary = computeMomentumSummary({
now: NOW,
exercises: [exercise({ id: 'pushups' })],
challenges: [challenge({ id: 'c1', enabled: false })],
history: [done('pushups', 0, 10)]
})
expect(summary.gameDebt.activeRules).toBe(0)
expect(summary.weeklyQuests.map((quest) => quest.id)).toEqual([
'week_rhythm',
'week_reps',
'today_anchor'
])
})
it('computes a soft level from reps, active days and match activity', () => {
const summary = computeMomentumSummary({
now: NOW,
exercises: [exercise({ id: 'pushups' })],
challenges: [challenge({ id: 'c1' })],
history: [
done('pushups', 0, 100),
done('pushups', 1, 100),
done('challenge:c1', 0, 100, 'match')
]
})
expect(summary.level.xp).toBe(425)
expect(summary.level.key).toBe('momentum.level.rhythm')
expect(summary.level.progressPct).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,216 @@
import type { Challenge, Exercise, HistoryEntry } from '@shared/types'
import { dayKey } from './history'
export type MomentumLevel = {
key: string
xp: number
levelIndex: number
current: number
target: number
progressPct: number
nextKey?: string
}
export type WeeklyQuest = {
id: 'week_rhythm' | 'week_reps' | 'match_debt' | 'today_anchor'
titleKey: string
descKey: string
current: number
target: number
progressPct: number
complete: boolean
tone: 'accent' | 'success' | 'warning' | 'info'
}
export type GameDebtSummary = {
activeRules: number
matchRepsToday: number
matchRepsWeek: number
matchEntriesToday: number
matchEntriesWeek: number
lastMatchAt?: number
}
export type MomentumSummary = {
level: MomentumLevel
weeklyQuests: WeeklyQuest[]
gameDebt: GameDebtSummary
weekReps: number
weekActiveDays: number
todayReps: number
}
const LEVELS = [
{ key: 'momentum.level.warmup', xp: 0 },
{ key: 'momentum.level.rhythm', xp: 120 },
{ key: 'momentum.level.steady', xp: 450 },
{ key: 'momentum.level.back', xp: 1000 },
{ key: 'momentum.level.machine', xp: 2500 },
{ key: 'momentum.level.legend', xp: 6000 }
] as const
function startOfDay(ts: number): Date {
const d = new Date(ts)
d.setHours(0, 0, 0, 0)
return d
}
function startOfWeek(ts: number): Date {
const d = startOfDay(ts)
const day = d.getDay()
const delta = day === 0 ? -6 : 1 - day
d.setDate(d.getDate() + delta)
return d
}
function entryReps(
entry: HistoryEntry,
exercisesById: Map<string, Exercise>
): number {
return (
entry.actualReps ??
entry.reps ??
exercisesById.get(entry.exerciseId)?.reps ??
0
)
}
function isMatchEntry(entry: HistoryEntry): boolean {
return entry.source === 'match' || entry.exerciseId.startsWith('challenge:')
}
function computeLevel(xp: number): MomentumLevel {
let index = 0
for (let i = 0; i < LEVELS.length; i++) {
if (xp >= LEVELS[i].xp) index = i
}
const currentLevel = LEVELS[index]
const nextLevel = LEVELS[index + 1]
const current = Math.max(0, xp - currentLevel.xp)
const target = nextLevel ? nextLevel.xp - currentLevel.xp : current || 1
const progressPct = nextLevel
? Math.min(100, Math.round((current / target) * 100))
: 100
return {
key: currentLevel.key,
xp,
levelIndex: index + 1,
current,
target,
progressPct,
nextKey: nextLevel?.key
}
}
function quest(
id: WeeklyQuest['id'],
current: number,
target: number,
tone: WeeklyQuest['tone']
): WeeklyQuest {
return {
id,
titleKey: `momentum.quest.${id}.title`,
descKey: `momentum.quest.${id}.desc`,
current,
target,
progressPct: Math.min(100, Math.round((current / target) * 100)),
complete: current >= target,
tone
}
}
export function computeMomentumSummary({
history,
exercises,
challenges,
now = Date.now()
}: {
history: HistoryEntry[]
exercises: Exercise[]
challenges: Challenge[]
now?: number
}): MomentumSummary {
const exercisesById = new Map(
exercises.map((exercise) => [exercise.id, exercise])
)
const today = dayKey(now)
const weekStart = startOfWeek(now).getTime()
let totalReps = 0
let todayReps = 0
let weekReps = 0
let matchRepsToday = 0
let matchRepsWeek = 0
let matchEntriesToday = 0
let matchEntriesWeek = 0
let lastMatchAt: number | undefined
const allActiveDays = new Set<string>()
const weekActiveDays = new Set<string>()
for (const entry of history) {
if (entry.action !== 'done') continue
const reps = entryReps(entry, exercisesById)
const key = dayKey(entry.ts)
const inWeek = entry.ts >= weekStart && entry.ts <= now
const match = isMatchEntry(entry)
totalReps += reps
allActiveDays.add(key)
if (key === today) todayReps += reps
if (inWeek) {
weekReps += reps
weekActiveDays.add(key)
}
if (match) {
if (lastMatchAt === undefined || entry.ts > lastMatchAt) {
lastMatchAt = entry.ts
}
if (key === today) {
matchEntriesToday++
matchRepsToday += reps
}
if (inWeek) {
matchEntriesWeek++
matchRepsWeek += reps
}
}
}
const activeRules = challenges.filter((challenge) => challenge.enabled).length
const xp = totalReps + allActiveDays.size * 50 + matchEntriesWeek * 25
const weeklyQuests: WeeklyQuest[] = [
quest('week_rhythm', weekActiveDays.size, 5, 'success'),
quest('week_reps', weekReps, 1000, 'accent'),
quest('today_anchor', todayReps > 0 ? 1 : 0, 1, 'info')
]
if (activeRules > 0) {
weeklyQuests.splice(
2,
0,
quest('match_debt', matchEntriesWeek, 3, 'warning')
)
}
return {
level: computeLevel(xp),
weeklyQuests,
gameDebt: {
activeRules,
matchRepsToday,
matchRepsWeek,
matchEntriesToday,
matchEntriesWeek,
lastMatchAt
},
weekReps,
weekActiveDays: weekActiveDays.size,
todayReps
}
}

View File

@@ -0,0 +1,32 @@
function errorFields(err: unknown): { message: string; stack?: string } {
if (err instanceof Error) {
return {
message: err.message || err.name,
stack: err.stack
}
}
if (typeof err === 'string') return { message: err }
try {
return { message: JSON.stringify(err) }
} catch {
return { message: String(err) }
}
}
function report(source: string, err: unknown): void {
const { message, stack } = errorFields(err)
if (!message) return
void window.api
?.reportRendererError?.({ source, message, stack })
.catch(() => undefined)
}
export function installRendererErrorReporting(): void {
window.addEventListener('error', (event) => {
report('window.error', event.error ?? event.message)
})
window.addEventListener('unhandledrejection', (event) => {
report('window.unhandledrejection', event.reason)
})
}

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,17 +1,33 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { MotionConfig } from 'framer-motion'
import './styles/globals.css' import './styles/globals.css'
import App from './App' import App from './App'
import ReminderApp from './ReminderApp' import ReminderApp from './ReminderApp'
import { ThemeProvider } from './providers/ThemeProvider' import { ThemeProvider } from './providers/ThemeProvider'
import { installRendererErrorReporting } from './lib/reporting'
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
const which = params.get('window') ?? 'main' const which = params.get('window') ?? 'main'
ReactDOM.createRoot(document.getElementById('root')!).render( // reducedMotion="user" — framer-motion сам читает системную настройку
<React.StrictMode> // «уменьшить движение» и глушит transform/layout-анимации (оставляя opacity).
<ThemeProvider> // Один источник истины для обоих окон и всех motion-компонентов.
{which === 'reminder' ? <ReminderApp /> : <App />} async function bootstrap(): Promise<void> {
</ThemeProvider> if (import.meta.env.DEV && !window.api) {
</React.StrictMode> const { installDevApi } = await import('./lib/dev-api')
) installDevApi()
}
installRendererErrorReporting()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MotionConfig reducedMotion="user">
<ThemeProvider>
{which === 'reminder' ? <ReminderApp /> : <App />}
</ThemeProvider>
</MotionConfig>
</React.StrictMode>
)
}
void bootstrap()

View File

@@ -1,7 +1,15 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Plus, ChevronRight, AlertTriangle, Gamepad2 } from 'lucide-react' import {
AlertTriangle,
BadgeCheck,
ChevronRight,
Gamepad2,
Plus,
Swords
} from 'lucide-react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { InsightCard, InsightGrid } from '../components/PageScaffold'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Modal } from '../components/ui/Modal' import { Modal } from '../components/ui/Modal'
import { Card, Row, SectionHeader } from '../components/ui/Card' import { Card, Row, SectionHeader } from '../components/ui/Card'
@@ -22,12 +30,16 @@ const GAME_NAMES: Record<GameId, string> = {
type Draft = Omit<Challenge, 'id'> type Draft = Omit<Challenge, 'id'>
// exerciseName умышленно пустой — пусть пользователь сам выберет что
// делать. Раньше дефолт был «Приседания» — в EN-локали это выглядело как
// баг (русский текст в английском UI). Required-валидация всё равно
// требует непустого значения перед save.
const EMPTY_DRAFT: Draft = { const EMPTY_DRAFT: Draft = {
name: '', name: '',
gameId: 'dota2', gameId: 'dota2',
stat: 'deaths', stat: 'deaths',
multiplier: 3, multiplier: 3,
exerciseName: 'Приседания', exerciseName: '',
icon: 'Activity', icon: 'Activity',
enabled: true enabled: true
} }
@@ -45,6 +57,12 @@ export default function ChallengesPage(): JSX.Element {
}, []) }, [])
const noGamesActive = games.length > 0 && !games.some((g) => g.enabled) const noGamesActive = games.length > 0 && !games.some((g) => g.enabled)
const activeChallenges = challenges.filter((c) => c.enabled)
const enabledGames = games.filter((g) => g.enabled).length
const previewDebt = activeChallenges.reduce(
(sum, challenge) => sum + Math.round(5 * challenge.multiplier),
0
)
return ( return (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
@@ -84,6 +102,30 @@ export default function ChallengesPage(): JSX.Element {
</div> </div>
)} )}
<InsightGrid>
<InsightCard
icon={<BadgeCheck size={17} strokeWidth={2.5} />}
label={t('challenges.insight.active')}
value={`${activeChallenges.length}/${challenges.length}`}
hint={t('challenges.insight.active.hint')}
tone={activeChallenges.length > 0 ? 'success' : 'muted'}
/>
<InsightCard
icon={<Gamepad2 size={17} strokeWidth={2.5} />}
label={t('challenges.insight.games')}
value={`${enabledGames}`}
hint={t('challenges.insight.games.hint')}
tone={enabledGames > 0 ? 'info' : 'warning'}
/>
<InsightCard
icon={<Swords size={17} strokeWidth={2.5} />}
label={t('challenges.insight.debt')}
value={t('challenges.insight.debt.value', { n: previewDebt })}
hint={t('challenges.insight.debt.hint')}
tone={previewDebt > 0 ? 'warning' : 'muted'}
/>
</InsightGrid>
{challenges.length > 0 ? ( {challenges.length > 0 ? (
<> <>
<SectionHeader <SectionHeader
@@ -135,8 +177,13 @@ export default function ChallengesPage(): JSX.Element {
</> </>
) : ( ) : (
<Card> <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">
{t('challenges.empty')} <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> </div>
</Card> </Card>
)} )}

View File

@@ -7,7 +7,14 @@ import {
Flame, Flame,
Activity, Activity,
TrendingUp, TrendingUp,
Video Video,
CalendarCheck,
Target,
RotateCcw,
Check,
Trophy,
Swords,
BadgeCheck
} from 'lucide-react' } from 'lucide-react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard' import { ExerciseCard } from '../components/ExerciseCard'
@@ -16,15 +23,31 @@ import { HistoryHeatmap } from '../components/HistoryHeatmap'
import { AchievementsCard } from '../components/AchievementsCard' import { AchievementsCard } from '../components/AchievementsCard'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { ConfirmModal } from '../components/ui/ConfirmModal' import { ConfirmModal } from '../components/ui/ConfirmModal'
import type { Exercise, GameStatus, HistoryEntry } from '@shared/types' import type {
Exercise,
GameStatus,
HistoryEntry,
Language
} from '@shared/types'
import { formatCountdown } from '../lib/format' import { formatCountdown } from '../lib/format'
import { useT } from '../i18n' import { useT, type TFn } from '../i18n'
import { Icon } from '../lib/icon'
import {
computeTodayPlan,
type PlanItem,
type TodayPlan
} from '../lib/day-plan'
import { import {
currentStreak, currentStreak,
dailyReps, dailyReps,
repsDoneTodayForExercise, repsDoneTodayForExercise,
todayKey todayKey
} from '../lib/history' } from '../lib/history'
import {
computeMomentumSummary,
type MomentumSummary,
type WeeklyQuest
} from '../lib/momentum'
export default function Dashboard(): JSX.Element { export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state) const state = useAppStore((s) => s.state)
@@ -38,7 +61,10 @@ export default function Dashboard(): JSX.Element {
// on every render — `state?.exercises ?? []` creates a fresh array each time // on every render — `state?.exercises ?? []` creates a fresh array each time
// the parent re-renders even when nothing changed. // the parent re-renders even when nothing changed.
const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises]) const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises])
const meals = useMemo(() => state?.meals ?? [], [state?.meals])
const challenges = useMemo(() => state?.challenges ?? [], [state?.challenges])
const settings = state?.settings const settings = state?.settings
const [planActionKey, setPlanActionKey] = useState<string | null>(null)
// Игры: запрашиваем реальный статус (integrationActive + launchOption // Игры: запрашиваем реальный статус (integrationActive + launchOption
// applied), а не просто `state.gamesEnabled`. Без этого badge показывал // applied), а не просто `state.gamesEnabled`. Без этого badge показывал
@@ -51,21 +77,28 @@ export default function Dashboard(): JSX.Element {
}, []) }, [])
const gamesLive = games.some( const gamesLive = games.some(
(g) => (g) =>
g.enabled && g.enabled && g.integrationActive && g.launchOptionStatus === 'applied'
g.integrationActive &&
g.launchOptionStatus === 'applied'
) )
// «Включена, но не готова» — отдельное состояние, в badge другой tone. // «Включена, но не готова» — отдельное состояние, в badge другой tone.
const gamesEnabledButNotLive = games.some( const gamesEnabledButNotLive = games.some(
(g) => g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied') (g) =>
g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied')
) )
// Local history mirror; reloaded only when exercises change (not on every // Local history mirror. Перетягиваем (а) на mount, (б) при изменении
// tick or settings tweak — those don't affect history). When ticks/settings // exercises (add/delete/edit — могут поменять name/icon в snapshot'ах
// change we don't re-fetch. // для будущих entries), (в) при evtHistoryChanged — это event который
// main отправляет ПОСЛЕ любого markDone/markChallengeDone/clearHistory/
// import. Без (в) heatmap и стрик стояли на месте после markDone —
// store мутирует exercise in place, ref не меняется, useEffect не
// fire'ил.
const [history, setHistory] = useState<HistoryEntry[]>([]) const [history, setHistory] = useState<HistoryEntry[]>([])
useEffect(() => { useEffect(() => {
void window.api.getHistory().then(setHistory) const refetch = (): void => {
void window.api.getHistory().then(setHistory)
}
refetch()
return window.api.onHistoryChanged(refetch)
}, [exercises]) }, [exercises])
// Meeting auto-pause indicator: подписываемся на evtMeetingChanged + // Meeting auto-pause indicator: подписываемся на evtMeetingChanged +
@@ -101,6 +134,15 @@ export default function Dashboard(): JSX.Element {
} }
}, [exercises, ticks]) }, [exercises, ticks])
const plan = useMemo(() => {
void ticks
return computeTodayPlan({ exercises, meals, history })
}, [exercises, meals, history, ticks])
const momentum = useMemo(
() => computeMomentumSummary({ history, exercises, challenges }),
[history, exercises, challenges]
)
const paused = !settings?.globalEnabled const paused = !settings?.globalEnabled
function openCreate(): void { function openCreate(): void {
@@ -129,23 +171,55 @@ export default function Dashboard(): JSX.Element {
if (!settings) return if (!settings) return
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled }) await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
} }
async function handlePlanItemDone(item: PlanItem): Promise<void> {
const key = `${item.kind}:${item.id}`
if (planActionKey !== null) return
setPlanActionKey(key)
try {
if (item.kind === 'meal') await window.api.markMealDone(item.id)
else await window.api.markDone(item.id)
} finally {
setPlanActionKey(null)
}
}
const today = new Date().toLocaleDateString( const today = new Date().toLocaleDateString(
lang === 'en' ? 'en-US' : 'ru-RU', lang === 'en' ? 'en-US' : 'ru-RU',
{ weekday: 'long', day: 'numeric', month: 'long' } { weekday: 'long', day: 'numeric', month: 'long' }
) )
const header = dashboardHeaderCopy({
plan,
paused,
meetingPaused,
hasSetup: exercises.length > 0 || meals.length > 0,
lang,
t
})
return ( return (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12"> <div className="max-w-5xl 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 className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[14px] text-text/65 font-semibold capitalize"> <div className="text-[14px] text-text/65 font-semibold">
{today} {t('dashboard.header.date', { date: today })}
</div> </div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold"> <div className="mt-2 flex flex-wrap items-center gap-3">
{t('dashboard.title')} <h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight font-bold">
</h1> {header.title}
</h1>
<span
className={[
'h-7 px-3 rounded-full inline-flex items-center text-[12px] font-bold uppercase tracking-[0.06em]',
dashboardStatusClass(header.tone)
].join(' ')}
>
{header.status}
</span>
</div>
<p className="text-[15px] text-text/65 mt-2 font-medium leading-relaxed max-w-2xl">
{header.subtitle}
</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="tinted" onClick={togglePause}> <Button variant="tinted" onClick={togglePause}>
@@ -203,7 +277,11 @@ export default function Dashboard(): JSX.Element {
/> />
<HeroStat <HeroStat
tone={ tone={
gamesLive ? 'success' : gamesEnabledButNotLive ? 'warning' : 'muted' gamesLive
? 'success'
: gamesEnabledButNotLive
? 'warning'
: 'muted'
} }
label={t('dashboard.stat.tracking')} label={t('dashboard.stat.tracking')}
value={ value={
@@ -231,13 +309,6 @@ export default function Dashboard(): JSX.Element {
/> />
</div> </div>
{history.length > 0 && (
<div className="mb-8 space-y-3">
<HistoryHeatmap history={history} exercises={exercises} />
<AchievementsCard history={history} exercises={exercises} />
</div>
)}
{paused && ( {paused && (
<motion.div <motion.div
initial={{ opacity: 0, y: -4 }} initial={{ opacity: 0, y: -4 }}
@@ -281,6 +352,30 @@ export default function Dashboard(): JSX.Element {
</motion.div> </motion.div>
)} )}
<TodayPlanPanel
plan={plan}
paused={paused}
lang={lang}
t={t}
actionBusy={planActionKey !== null}
onItemDone={(item) => void handlePlanItemDone(item)}
/>
<MomentumPanel
momentum={momentum}
gamesLive={gamesLive}
gamesEnabledButNotLive={gamesEnabledButNotLive}
lang={lang}
t={t}
/>
{history.length > 0 && (
<div className="mb-8 space-y-3">
<HistoryHeatmap history={history} exercises={exercises} />
<AchievementsCard history={history} exercises={exercises} />
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
<AnimatePresence> <AnimatePresence>
{exercises.map((ex) => ( {exercises.map((ex) => (
@@ -340,6 +435,677 @@ export default function Dashboard(): JSX.Element {
) )
} }
type DashboardHeaderTone = 'accent' | 'success' | 'warning' | 'info' | 'muted'
function dashboardHeaderCopy({
plan,
paused,
meetingPaused,
hasSetup,
lang,
t
}: {
plan: TodayPlan
paused: boolean
meetingPaused: boolean
hasSetup: boolean
lang: Language
t: TFn
}): {
title: string
subtitle: string
status: string
tone: DashboardHeaderTone
} {
if (paused) {
return {
title: t('dashboard.header.title.paused'),
subtitle: t('dashboard.header.subtitle.paused'),
status: t('dashboard.header.status.paused'),
tone: 'muted'
}
}
if (meetingPaused) {
return {
title: t('dashboard.header.title.meeting'),
subtitle: t('dashboard.header.subtitle.meeting'),
status: t('dashboard.header.status.meeting'),
tone: 'info'
}
}
if (!hasSetup) {
return {
title: t('dashboard.header.title.empty'),
subtitle: t('dashboard.header.subtitle.empty'),
status: t('dashboard.header.status.clear'),
tone: 'warning'
}
}
const nextItem = plan.nextItem
if (nextItem) {
const payload = {
name: nextItem.name,
kind: t(`dashboard.plan.kind.${nextItem.kind}`),
meta: planItemMeta(nextItem, t),
time: planItemTiming(nextItem, false, lang, t)
}
if (nextItem.due) {
return {
title: t('dashboard.header.title.due', payload),
subtitle: t('dashboard.header.subtitle.due', payload),
status: t('dashboard.header.status.due'),
tone: 'accent'
}
}
return {
title: t('dashboard.header.title.next', payload),
subtitle: t('dashboard.header.subtitle.next', payload),
status: t('dashboard.header.status.running'),
tone: 'success'
}
}
return {
title: t('dashboard.header.title.clear'),
subtitle: t('dashboard.header.subtitle.clear'),
status: t('dashboard.header.status.clear'),
tone: 'success'
}
}
function dashboardStatusClass(tone: DashboardHeaderTone): string {
if (tone === 'accent') return 'bg-accent/12 text-accent'
if (tone === 'success') return 'bg-success/12 text-success'
if (tone === 'warning') return 'bg-warning/12 text-warning'
if (tone === 'info') return 'bg-info/12 text-info'
return 'bg-text/10 text-text/55'
}
function MomentumPanel({
momentum,
gamesLive,
gamesEnabledButNotLive,
lang,
t
}: {
momentum: MomentumSummary
gamesLive: boolean
gamesEnabledButNotLive: boolean
lang: Language
t: TFn
}): JSX.Element {
const gameStatus = gamesLive
? t('momentum.game.live')
: gamesEnabledButNotLive
? t('momentum.game.setup')
: t('momentum.game.off')
return (
<section className="mb-8 grid grid-cols-1 lg:grid-cols-[0.9fr_1.1fr_0.95fr] gap-4">
<div className="bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30 min-w-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-warning text-white grid place-items-center shrink-0">
<Trophy size={19} strokeWidth={2.5} />
</div>
<div className="min-w-0">
<div className="text-[13px] text-text/55 font-semibold">
{t('momentum.level.title')}
</div>
<div className="font-display text-[22px] font-bold leading-tight truncate">
{t(momentum.level.key)}
</div>
</div>
</div>
<div className="mt-5 flex items-end justify-between gap-3">
<div>
<div className="text-[12px] text-text/55 font-semibold">
{t('momentum.level.number', {
n: momentum.level.levelIndex
})}
</div>
<div className="font-mono-num text-[28px] leading-none font-bold">
{momentum.level.xp}
</div>
</div>
<div className="text-[13px] text-text/60 text-right">
{momentum.level.nextKey
? t('momentum.level.next', {
name: t(momentum.level.nextKey),
n: momentum.level.target - momentum.level.current
})
: t('momentum.level.max')}
</div>
</div>
<ProgressBar pct={momentum.level.progressPct} tone="warning" />
</div>
<div className="bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30 min-w-0">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0">
<div className="text-[13px] text-text/55 font-semibold">
{t('momentum.week.kicker')}
</div>
<h2 className="font-display text-[22px] font-bold leading-tight">
{t('momentum.week.title')}
</h2>
</div>
<div className="text-[12px] text-text/55 font-mono-num font-semibold shrink-0">
{t('momentum.week.summary', {
days: momentum.weekActiveDays,
reps: momentum.weekReps
})}
</div>
</div>
<div className="divide-y divide-hairline/35">
{momentum.weeklyQuests.map((quest) => (
<WeeklyQuestRow key={quest.id} quest={quest} t={t} />
))}
</div>
</div>
<div className="bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30 min-w-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-accent-2 text-white grid place-items-center shrink-0">
<Swords size={19} strokeWidth={2.5} />
</div>
<div className="min-w-0">
<div className="text-[13px] text-text/55 font-semibold">
{t('momentum.game.kicker')}
</div>
<h2 className="font-display text-[22px] font-bold leading-tight">
{t('momentum.game.title')}
</h2>
</div>
</div>
<div className="mt-4 rounded-2xl bg-surface-2 p-4">
<div className="flex items-center justify-between gap-3">
<div className="text-[13px] text-text/60 font-semibold">
{t('momentum.game.status')}
</div>
<div
className={[
'text-[12px] px-2.5 py-1 rounded-full font-semibold',
gamesLive
? 'bg-success/12 text-success'
: gamesEnabledButNotLive
? 'bg-warning/12 text-warning'
: 'bg-text/10 text-text/55'
].join(' ')}
>
{gameStatus}
</div>
</div>
{momentum.gameDebt.activeRules > 0 ? (
<>
<div className="mt-4 grid grid-cols-2 gap-3">
<GameDebtStat
label={t('momentum.game.today')}
value={t('momentum.game.reps', {
n: momentum.gameDebt.matchRepsToday
})}
hint={t('momentum.game.entries', {
n: momentum.gameDebt.matchEntriesToday
})}
/>
<GameDebtStat
label={t('momentum.game.week')}
value={t('momentum.game.reps', {
n: momentum.gameDebt.matchRepsWeek
})}
hint={t('momentum.game.entries', {
n: momentum.gameDebt.matchEntriesWeek
})}
/>
</div>
<div className="mt-4 text-[13px] text-text/62 leading-relaxed">
{momentum.gameDebt.lastMatchAt
? t('momentum.game.last', {
date: new Date(
momentum.gameDebt.lastMatchAt
).toLocaleDateString(lang === 'en' ? 'en-US' : 'ru-RU', {
day: 'numeric',
month: 'short'
})
})
: t('momentum.game.no_matches')}
</div>
</>
) : (
<div className="mt-4 text-[14px] text-text/65 leading-relaxed">
{t('momentum.game.no_rules')}
</div>
)}
</div>
</div>
</section>
)
}
function WeeklyQuestRow({
quest,
t
}: {
quest: WeeklyQuest
t: TFn
}): JSX.Element {
const IconCmp =
quest.id === 'match_debt'
? Swords
: quest.id === 'today_anchor'
? BadgeCheck
: quest.id === 'week_rhythm'
? Flame
: TrendingUp
return (
<div className="py-3 flex items-center gap-3 min-w-0">
<div
className={[
'w-9 h-9 rounded-xl grid place-items-center text-white shrink-0',
quest.complete
? 'bg-success'
: quest.tone === 'warning'
? 'bg-warning'
: quest.tone === 'info'
? 'bg-info'
: 'bg-accent'
].join(' ')}
>
<IconCmp size={16} strokeWidth={2.5} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-[15px] truncate">
{t(quest.titleKey)}
</div>
<div className="font-mono-num text-[12px] text-text/55 shrink-0">
{quest.current}/{quest.target}
</div>
</div>
<div className="text-[12px] text-text/55 mt-1 truncate">
{quest.complete ? t('momentum.quest.complete') : t(quest.descKey)}
</div>
<ProgressBar pct={quest.progressPct} tone={quest.tone} compact />
</div>
</div>
)
}
function GameDebtStat({
label,
value,
hint
}: {
label: string
value: string
hint: string
}): JSX.Element {
return (
<div className="min-w-0">
<div className="text-[12px] text-text/55 font-semibold">{label}</div>
<div className="font-mono-num text-[20px] font-bold leading-tight mt-1 truncate">
{value}
</div>
<div className="text-[11px] text-text/50 mt-1 truncate">{hint}</div>
</div>
)
}
function TodayPlanPanel({
plan,
paused,
lang,
t,
actionBusy,
onItemDone
}: {
plan: TodayPlan
paused: boolean
lang: Language
t: TFn
actionBusy: boolean
onItemDone: (item: PlanItem) => void
}): JSX.Element {
const nextItem = plan.nextItem
const upcoming = plan.items.slice(nextItem ? 1 : 0, nextItem ? 4 : 3)
const goalPct =
plan.goalTarget > 0
? Math.min(100, Math.round((plan.goalDone / plan.goalTarget) * 100))
: 0
const mealPct =
plan.enabledMeals > 0
? Math.min(100, Math.round((plan.doneMeals / plan.enabledMeals) * 100))
: 0
const recovery = recoveryCopy(plan, t)
return (
<section className="mb-8 bg-surface rounded-3xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-2xl bg-accent/12 text-accent grid place-items-center shrink-0">
<CalendarCheck size={19} strokeWidth={2.5} />
</div>
<div className="min-w-0">
<h2 className="font-display text-[22px] font-bold leading-tight">
{t('dashboard.plan.title')}
</h2>
<div className="text-[14px] text-text/60 mt-0.5">
{t('dashboard.plan.subtitle')}
</div>
</div>
</div>
<div
className={[
'self-start sm:self-auto h-8 px-3 rounded-full inline-flex items-center text-[13px] font-semibold',
plan.dueCount > 0
? 'bg-accent/12 text-accent'
: 'bg-success/12 text-success'
].join(' ')}
>
{plan.dueCount > 0
? t('dashboard.plan.due_count', { n: plan.dueCount })
: t('dashboard.plan.all_caught_up')}
</div>
</div>
<div className="mt-5 grid grid-cols-1 lg:grid-cols-[1.35fr_0.95fr_0.95fr] border-y border-hairline/35 divide-y lg:divide-y-0 lg:divide-x divide-hairline/35">
<div className="py-4 lg:pr-5 min-w-0">
<div className="text-[13px] text-text/55 font-semibold">
{t('dashboard.plan.next_action')}
</div>
{nextItem ? (
<>
<div className="mt-3 flex items-center gap-3 min-w-0">
<PlanItemGlyph item={nextItem} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="font-display text-[20px] leading-tight font-bold truncate">
{nextItem.name}
</div>
{nextItem.due && (
<span className="shrink-0 w-2 h-2 rounded-full bg-accent" />
)}
</div>
<div className="text-[13px] text-text/60 mt-1 truncate">
{t(`dashboard.plan.kind.${nextItem.kind}`)} ·{' '}
{planItemMeta(nextItem, t)}
</div>
</div>
</div>
<div className="mt-4 flex flex-col sm:flex-row sm:items-center gap-3">
<div className="text-[14px] text-text/70 flex-1 min-w-0">
{planItemTiming(nextItem, paused, lang, t)}
</div>
<Button
size="sm"
variant={nextItem.kind === 'meal' ? 'success' : 'filled'}
disabled={actionBusy}
onClick={() => onItemDone(nextItem)}
className="sm:w-auto w-full"
>
<Check size={14} strokeWidth={2.5} />
{nextItem.kind === 'meal'
? t('dashboard.plan.ate_now')
: t('dashboard.plan.done_now')}
</Button>
</div>
</>
) : (
<div className="mt-3 min-w-0">
<div className="font-display text-[20px] leading-tight font-bold">
{t('dashboard.plan.clear.title')}
</div>
<div className="text-[14px] text-text/60 mt-1">
{t('dashboard.plan.clear.hint')}
</div>
</div>
)}
</div>
<div className="py-4 lg:px-5 min-w-0">
<div className="flex items-center gap-2 text-[13px] text-text/55 font-semibold">
<Target size={14} strokeWidth={2.5} />
{t('dashboard.plan.goals')}
</div>
<div className="mt-3 flex items-end justify-between gap-3">
<div className="font-mono-num text-[24px] font-bold leading-none">
{plan.goalTarget > 0
? t('dashboard.plan.goals.progress', {
done: plan.goalDone,
goal: plan.goalTarget
})
: '—'}
</div>
{plan.goalTarget > 0 && (
<div className="text-[13px] text-text/60">
{t('dashboard.plan.goals.remaining', {
n: plan.goalRemaining
})}
</div>
)}
</div>
<ProgressBar pct={goalPct} tone="accent" />
<div className="text-[13px] text-text/58 mt-3">
{plan.goalTarget > 0
? t('dashboard.plan.goals.hint')
: t('dashboard.plan.goals.empty')}
</div>
{plan.enabledMeals > 0 && (
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<div className="text-[13px] text-text/55 font-semibold">
{t('dashboard.plan.meals')}
</div>
<div className="font-mono-num text-[14px] font-bold">
{t('dashboard.plan.meals.progress', {
done: plan.doneMeals,
total: plan.enabledMeals
})}
</div>
</div>
<ProgressBar pct={mealPct} tone="success" />
</div>
)}
</div>
<div className="py-4 lg:pl-5 min-w-0">
<div className="flex items-center gap-2 text-[13px] text-text/55 font-semibold">
<RotateCcw size={14} strokeWidth={2.5} />
{t('dashboard.plan.recovery')}
</div>
<div className="mt-3 flex items-start gap-3">
<div
className={[
'w-9 h-9 rounded-xl grid place-items-center shrink-0',
recovery.tone
].join(' ')}
>
<RotateCcw size={17} strokeWidth={2.5} />
</div>
<div className="min-w-0">
<div className="font-display text-[20px] leading-tight font-bold">
{recovery.title}
</div>
<div className="text-[14px] text-text/60 mt-1">
{recovery.hint}
</div>
</div>
</div>
</div>
</div>
{upcoming.length > 0 && (
<div className="pt-4">
<div className="text-[13px] text-text/55 font-semibold mb-2">
{t('dashboard.plan.up_next')}
</div>
<div className="divide-y divide-hairline/35">
{upcoming.map((item) => (
<PlanListRow
key={`${item.kind}:${item.id}`}
item={item}
paused={paused}
lang={lang}
t={t}
/>
))}
</div>
</div>
)}
</section>
)
}
function PlanItemGlyph({ item }: { item: PlanItem }): JSX.Element {
const dueClass = item.due
? 'bg-accent text-white'
: item.kind === 'meal'
? 'bg-success/12 text-success'
: 'bg-accent/12 text-accent'
return (
<div
className={[
'w-11 h-11 rounded-2xl grid place-items-center shrink-0',
dueClass
].join(' ')}
>
<Icon name={item.icon} size={20} />
</div>
)
}
function PlanListRow({
item,
paused,
lang,
t
}: {
item: PlanItem
paused: boolean
lang: Language
t: TFn
}): JSX.Element {
return (
<div className="py-3 flex items-center gap-3 min-w-0">
<PlanItemGlyph item={item} />
<div className="flex-1 min-w-0">
<div className="font-semibold text-[15px] leading-tight truncate">
{item.name}
</div>
<div className="text-[13px] text-text/58 mt-1 truncate">
{planItemMeta(item, t)}
</div>
</div>
<div className="text-[13px] text-text/62 shrink-0 max-w-[42%] truncate">
{planItemTiming(item, paused, lang, t)}
</div>
</div>
)
}
function ProgressBar({
pct,
tone,
compact = false
}: {
pct: number
tone: 'accent' | 'success' | 'warning' | 'info'
compact?: boolean
}): JSX.Element {
const toneClass =
tone === 'accent'
? 'bg-accent'
: tone === 'success'
? 'bg-success'
: tone === 'warning'
? 'bg-warning'
: 'bg-info'
return (
<div
className={[
'rounded-full bg-hairline/35 overflow-hidden',
compact ? 'mt-2 h-1.5' : 'mt-3 h-2'
].join(' ')}
>
<div
className={[
'h-full rounded-full transition-all duration-300',
toneClass
].join(' ')}
style={{ width: `${pct}%` }}
/>
</div>
)
}
function planItemMeta(item: PlanItem, t: TFn): string {
if (item.kind === 'meal') {
return item.time
? t('dashboard.plan.meal_time', { time: item.time })
: t('dashboard.plan.kind.meal')
}
if (item.goal !== undefined) {
return t('dashboard.plan.item.remaining_reps', {
n: item.remainingReps ?? 0
})
}
return t('dashboard.plan.item.reps', { n: item.reps ?? 1 })
}
function planItemTiming(
item: PlanItem,
paused: boolean,
lang: Language,
t: TFn
): string {
if (item.due) return t('dashboard.plan.due_now')
if (paused) return t('dashboard.plan.paused')
return t('dashboard.plan.next_in', {
time: formatCountdown(item.nextFireAt - Date.now(), lang)
})
}
function recoveryCopy(
plan: TodayPlan,
t: TFn
): { title: string; hint: string; tone: string } {
if (plan.recovery.kind === 'first-run') {
return {
title: t('dashboard.plan.recovery.first.title'),
hint: t('dashboard.plan.recovery.first.hint'),
tone: 'bg-info/12 text-info'
}
}
if (plan.recovery.kind === 'recovery') {
return {
title: t('dashboard.plan.recovery.return.title'),
hint: t('dashboard.plan.recovery.return.hint', {
n: plan.recovery.daysSinceDone
}),
tone: 'bg-warning/12 text-warning'
}
}
return {
title: t('dashboard.plan.recovery.steady.title'),
hint:
plan.recovery.daysSinceDone === 0
? t('dashboard.plan.recovery.steady.today')
: plan.recovery.daysSinceDone === 1
? t('dashboard.plan.recovery.steady.yesterday')
: t('dashboard.plan.recovery.steady.none'),
tone: 'bg-success/12 text-success'
}
}
function HeroStat({ function HeroStat({
tone, tone,
label, label,

View File

@@ -1,7 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { Plus, ChevronRight } from 'lucide-react' import { Activity, ChevronRight, Dumbbell, Plus, Target } from 'lucide-react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { ExerciseEditor } from '../components/ExerciseEditor' import { ExerciseEditor } from '../components/ExerciseEditor'
import {
InsightCard,
InsightGrid,
PageHeader
} from '../components/PageScaffold'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card' import { Card, Row, SectionHeader } from '../components/ui/Card'
@@ -18,28 +23,51 @@ export default function Exercises(): JSX.Element {
const enabled = exercises.filter((e) => e.enabled) const enabled = exercises.filter((e) => e.enabled)
const disabled = exercises.filter((e) => !e.enabled) const disabled = exercises.filter((e) => !e.enabled)
const goalCount = exercises.filter((e) => e.dailyGoal !== undefined).length
const totalReps = enabled.reduce((sum, e) => sum + e.reps, 0)
return ( return (
<div className="h-full overflow-y-auto"> <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="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"> <PageHeader
<div> kicker={t('exercises.kicker')}
<div className="text-[14px] text-text/65 font-semibold"> title={t('exercises.title')}
{t('exercises.kicker')} subtitle={t('exercises.subtitle')}
</div> action={
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold"> <Button
{t('exercises.title')} onClick={() => {
</h1> setEditing(null)
</div> setEditorOpen(true)
<Button }}
onClick={() => { >
setEditing(null) <Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
setEditorOpen(true) </Button>
}} }
> />
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
</Button> <InsightGrid>
</div> <InsightCard
icon={<Activity size={17} strokeWidth={2.5} />}
label={t('exercises.insight.active')}
value={`${enabled.length}/${exercises.length}`}
hint={t('exercises.insight.active.hint')}
tone={enabled.length > 0 ? 'success' : 'muted'}
/>
<InsightCard
icon={<Dumbbell size={17} strokeWidth={2.5} />}
label={t('exercises.insight.load')}
value={t('exercises.insight.load.value', { n: totalReps })}
hint={t('exercises.insight.load.hint')}
tone="accent"
/>
<InsightCard
icon={<Target size={17} strokeWidth={2.5} />}
label={t('exercises.insight.goals')}
value={`${goalCount}`}
hint={t('exercises.insight.goals.hint')}
tone={goalCount > 0 ? 'info' : 'muted'}
/>
</InsightGrid>
{enabled.length > 0 && ( {enabled.length > 0 && (
<> <>
@@ -93,8 +121,13 @@ export default function Exercises(): JSX.Element {
{exercises.length === 0 && ( {exercises.length === 0 && (
<Card> <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">
{t('exercises.empty')} <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> </div>
</Card> </Card>
)} )}

View File

@@ -9,8 +9,10 @@ import {
AlertTriangle AlertTriangle
} from 'lucide-react' } from 'lucide-react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { InsightCard, InsightGrid } from '../components/PageScaffold'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Spinner } from '../components/ui/Spinner'
import { Card, SectionHeader } from '../components/ui/Card' import { Card, SectionHeader } from '../components/ui/Card'
import type { GameId, GameStatus } from '@shared/types' import type { GameId, GameStatus } from '@shared/types'
import { useT } from '../i18n' import { useT } from '../i18n'
@@ -54,7 +56,14 @@ export default function GamesPage(): JSX.Element {
} }
} }
const liveCount = games.filter((g) => g.enabled && g.integrationActive).length const connectedCount = games.filter((g) => g.integrationActive).length
const liveCount = games.filter(
(g) =>
g.enabled && g.integrationActive && g.launchOptionStatus === 'applied'
).length
const queuedCount = games.filter(
(g) => g.integrationActive && g.launchOptionStatus === 'queued'
).length
return ( return (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
@@ -84,6 +93,36 @@ export default function GamesPage(): JSX.Element {
</Button> </Button>
</div> </div>
<InsightGrid>
<InsightCard
icon={<Gamepad2 size={17} strokeWidth={2.5} />}
label={t('games.insight.supported')}
value={`${games.length}`}
hint={t('games.insight.supported.hint')}
tone="info"
/>
<InsightCard
icon={<CheckCircle2 size={17} strokeWidth={2.5} />}
label={t('games.insight.connected')}
value={`${connectedCount}`}
hint={t('games.insight.connected.hint')}
tone={connectedCount > 0 ? 'success' : 'muted'}
/>
<InsightCard
icon={<Hourglass size={17} strokeWidth={2.5} />}
label={t('games.insight.live')}
value={
queuedCount > 0
? t('games.insight.queued', { n: queuedCount })
: t('games.subtitle.live', { n: liveCount })
}
hint={t('games.insight.live.hint')}
tone={
liveCount > 0 ? 'success' : queuedCount > 0 ? 'warning' : 'muted'
}
/>
</InsightGrid>
<SectionHeader title={t('games.section.supported')} /> <SectionHeader title={t('games.section.supported')} />
<div className="space-y-4"> <div className="space-y-4">
{games.map((g, i) => ( {games.map((g, i) => (
@@ -104,7 +143,15 @@ export default function GamesPage(): JSX.Element {
))} ))}
{games.length === 0 && ( {games.length === 0 && (
<Card> <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')} {t('games.scanning')}
</div> </div>
</Card> </Card>
@@ -201,7 +248,12 @@ function GameCard({
<div className="flex items-center flex-wrap gap-2 mt-4"> <div className="flex items-center flex-wrap gap-2 mt-4">
{game.installed && !game.integrationActive && ( {game.installed && !game.integrationActive && (
<Button onClick={onInstall} disabled={busy} size="sm"> <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> </Button>
)} )}
{game.integrationActive && ( {game.integrationActive && (
@@ -211,7 +263,12 @@ function GameCard({
disabled={busy} disabled={busy}
size="sm" 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> </Button>
)} )}
{!game.installed && ( {!game.installed && (

View File

@@ -0,0 +1,248 @@
import { useState } from 'react'
import {
CalendarDays,
ChevronRight,
Clock,
Plus,
UtensilsCrossed
} from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { useAppStore } from '../store/appStore'
import { MealEditor, type MealDraft } from '../components/MealEditor'
import {
InsightCard,
InsightGrid,
PageHeader
} from '../components/PageScaffold'
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()
// Единый список (включённые сверху). Важно: НЕ разбиваем на два <Card>, иначе
// при переключении строка переезжает между списками → её Switch
// размонтируется/монтируется заново, и анимация ползунка есть только при
// включении (mount с x:0→20), а при выключении нет. В одном keyed-списке
// компонент остаётся смонтированным → ползунок плавно ездит в обе стороны,
// а строка «переезжает» в свою группу через layout-анимацию.
const ordered = [...meals].sort((a, b) =>
a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1
)
const activeMeals = meals.filter((m) => m.enabled)
const nextMeal = getNextMealLabel(activeMeals)
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">
<PageHeader
kicker={t('meals.kicker')}
title={t('meals.title')}
subtitle={t('meals.subtitle')}
action={
<Button
onClick={() => {
setEditing(null)
setEditorOpen(true)
}}
>
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
</Button>
}
/>
<InsightGrid>
<InsightCard
icon={<UtensilsCrossed size={17} strokeWidth={2.5} />}
label={t('meals.insight.active')}
value={`${activeMeals.length}/${meals.length}`}
hint={t('meals.insight.active.hint')}
tone={activeMeals.length > 0 ? 'success' : 'muted'}
/>
<InsightCard
icon={<Clock size={17} strokeWidth={2.5} />}
label={t('meals.insight.next')}
value={nextMeal ?? '—'}
hint={t('meals.insight.next.hint')}
tone={nextMeal ? 'accent' : 'muted'}
/>
<InsightCard
icon={<CalendarDays size={17} strokeWidth={2.5} />}
label={t('meals.insight.presets')}
value={`${MEAL_PRESETS.length}`}
hint={t('meals.insight.presets.hint')}
tone="info"
/>
</InsightGrid>
{/* Пресеты быстрого добавления */}
<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>
{meals.length > 0 && (
<>
<SectionHeader title={t('meals.schedule')} />
<Card>
<AnimatePresence initial={false}>
{ordered.map((m, i) => (
<motion.div
key={m.id}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
>
<MealRow
meal={m}
last={i === ordered.length - 1}
meta={`${m.time} · ${daysLabel(m.days, t)}`}
onEdit={() => {
setEditing(m)
setEditorOpen(true)
}}
/>
</motion.div>
))}
</AnimatePresence>
</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 getNextMealLabel(meals: Meal[]): string | null {
const today = new Date().getDay()
const now = new Date()
const nowMinutes = now.getHours() * 60 + now.getMinutes()
const candidates = meals
.filter((meal) => meal.days.length === 0 || meal.days.includes(today))
.map((meal) => {
const [hh, mm] = meal.time.split(':').map(Number)
return {
meal,
minutes: (hh || 0) * 60 + (mm || 0)
}
})
.filter((item) => item.minutes >= nowMinutes)
.sort((a, b) => a.minutes - b.minutes)
return candidates[0]?.meal.time ?? null
}
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 transition-colors duration-200',
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

@@ -1,25 +1,51 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import {
Bell,
Copy,
FolderOpen,
Languages,
Palette,
Power,
RefreshCw
} from 'lucide-react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Button } from '../components/ui/Button'
import { InsightCard, InsightGrid } from '../components/PageScaffold'
import { Card, Row, SectionHeader } from '../components/ui/Card' import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard' import { UpdaterCard } from '../components/UpdaterCard'
import { WhatsNewModal } from '../components/WhatsNewModal' import { WhatsNewModal } from '../components/WhatsNewModal'
import { ConfirmModal } from '../components/ui/ConfirmModal' 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 { RELEASE_NOTES } from '@shared/release-notes'
import { useT } from '../i18n' import { translate, useT, type TFn } from '../i18n'
import type { import type {
DiagnosticsInfo,
Language, Language,
NotificationMode, NotificationMode,
QuietHours, QuietHours,
Settings as SettingsType, Settings as SettingsType,
Theme Theme
} from '@shared/types' } from '@shared/types'
import { parseHHMM } from '@shared/types'
export default function SettingsPage(): JSX.Element { export default function SettingsPage(): JSX.Element {
const settings = useAppStore((s) => s.state?.settings) const settings = useAppStore((s) => s.state?.settings)
const { t } = useT() const { t } = useT()
if (!settings) if (!settings)
return <div className="p-8 text-text/45">{t('settings.loading')}</div> return (
<div
className="max-w-3xl 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 => { const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p) window.api.updateSettings(p)
@@ -27,8 +53,8 @@ export default function SettingsPage(): JSX.Element {
return ( return (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12"> <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="mb-8"> <div className="mb-6">
<div className="text-[14px] text-text/65 font-semibold"> <div className="text-[14px] text-text/65 font-semibold">
{t('settings.kicker')} {t('settings.kicker')}
</div> </div>
@@ -37,7 +63,38 @@ export default function SettingsPage(): JSX.Element {
</h1> </h1>
</div> </div>
<SectionHeader title={t('settings.section.language')} /> <SettingsStatusPanel
settings={settings}
onToggleGlobal={() =>
patch({ globalEnabled: !settings.globalEnabled })
}
/>
<InsightGrid>
<InsightCard
icon={<Bell size={17} strokeWidth={2.5} />}
label={t('settings.insight.mode')}
value={t(`settings.notification_mode.${settings.notificationMode}`)}
hint={t('settings.insight.mode.hint')}
tone={settings.soundEnabled ? 'success' : 'info'}
/>
<InsightCard
icon={<Palette size={17} strokeWidth={2.5} />}
label={t('settings.insight.theme')}
value={t(`settings.theme.${settings.theme}`)}
hint={t('settings.insight.theme.hint')}
tone="accent"
/>
<InsightCard
icon={<Languages size={17} strokeWidth={2.5} />}
label={t('settings.insight.language')}
value={t(`settings.language.${settings.language}`)}
hint={t('settings.insight.language.hint')}
tone="info"
/>
</InsightGrid>
<SectionHeader title={t('settings.section.interface')} />
<Card className="mb-6"> <Card className="mb-6">
<SelectRow <SelectRow
label={t('settings.language.label')} label={t('settings.language.label')}
@@ -48,12 +105,29 @@ export default function SettingsPage(): JSX.Element {
{ value: 'ru', label: t('settings.language.ru') }, { value: 'ru', label: t('settings.language.ru') },
{ value: 'en', label: t('settings.language.en') } { value: 'en', label: t('settings.language.en') }
]} ]}
/>
<SelectRow
label={t('settings.theme.label')}
hint={t('settings.theme.hint')}
value={settings.theme}
onChange={(v) => patch({ theme: v as Theme })}
options={[
{ value: 'system', label: t('settings.theme.system') },
{ value: 'light', label: t('settings.theme.light') },
{ value: 'dark', label: t('settings.theme.dark') }
]}
last last
/> />
</Card> </Card>
<SectionHeader title={t('settings.section.reminders')} /> <SectionHeader title={t('settings.section.reminders')} />
<Card className="mb-6"> <Card className="mb-6">
<ToggleRow
label={t('settings.global.label')}
hint={t('settings.global.hint')}
checked={settings.globalEnabled}
onChange={(v) => patch({ globalEnabled: v })}
/>
<SelectRow <SelectRow
label={t('settings.notification_mode.label')} label={t('settings.notification_mode.label')}
hint={t('settings.notification_mode.hint')} hint={t('settings.notification_mode.hint')}
@@ -155,22 +229,6 @@ export default function SettingsPage(): JSX.Element {
/> />
</Card> </Card>
<SectionHeader title={t('settings.section.appearance')} />
<Card className="mb-6">
<SelectRow
label={t('settings.theme.label')}
hint={t('settings.theme.hint')}
value={settings.theme}
onChange={(v) => patch({ theme: v as Theme })}
options={[
{ value: 'system', label: t('settings.theme.system') },
{ value: 'light', label: t('settings.theme.light') },
{ value: 'dark', label: t('settings.theme.dark') }
]}
last
/>
</Card>
<SectionHeader title={t('settings.section.updates')} /> <SectionHeader title={t('settings.section.updates')} />
<UpdaterCard /> <UpdaterCard />
@@ -179,6 +237,11 @@ export default function SettingsPage(): JSX.Element {
<DataCard /> <DataCard />
</div> </div>
<div className="mt-6">
<SectionHeader title={t('settings.section.diagnostics')} />
<DiagnosticsCard />
</div>
<div className="mt-6"> <div className="mt-6">
<SectionHeader title={t('settings.section.about')} /> <SectionHeader title={t('settings.section.about')} />
<AboutCard /> <AboutCard />
@@ -188,9 +251,289 @@ export default function SettingsPage(): JSX.Element {
) )
} }
function SettingsStatusPanel({
settings,
onToggleGlobal
}: {
settings: SettingsType
onToggleGlobal: () => void
}): JSX.Element {
const { t } = useT()
return (
<section className="mb-6 rounded-3xl bg-surface p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<div
className={[
'w-11 h-11 rounded-2xl grid place-items-center text-white shrink-0',
settings.globalEnabled ? 'bg-success' : 'bg-warning'
].join(' ')}
>
<Power size={20} strokeWidth={2.5} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] uppercase tracking-[0.06em] text-text/50 font-bold">
{t('settings.status.kicker')}
</div>
<h2 className="font-display text-[22px] font-bold leading-tight mt-1">
{settings.globalEnabled
? t('settings.status.title.on')
: t('settings.status.title.off')}
</h2>
<p className="text-[14px] text-text/62 mt-1 leading-relaxed max-w-xl break-words">
{settingsStatusHint(settings, t)}
</p>
</div>
</div>
<Button
type="button"
variant={settings.globalEnabled ? 'tinted' : 'filled'}
onClick={onToggleGlobal}
className="self-start sm:self-auto"
>
{settings.globalEnabled ? t('btn.pause') : t('btn.start')}
</Button>
</div>
<div className="mt-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
<StatusPill
label={t('settings.status.reminders')}
value={
settings.globalEnabled
? t('settings.status.on')
: t('settings.status.off')
}
active={settings.globalEnabled}
/>
<StatusPill
label={t('settings.status.quiet')}
value={
settings.quietHours.enabled
? `${settings.quietHours.from}-${settings.quietHours.to}`
: t('settings.status.off')
}
active={settings.quietHours.enabled}
tone="info"
/>
<StatusPill
label={t('settings.status.meetings')}
value={
settings.meetingAutoPause
? t('settings.status.on')
: t('settings.status.off')
}
active={settings.meetingAutoPause}
tone="info"
/>
<StatusPill
label={t('settings.status.autostart')}
value={
settings.startWithWindows
? t('settings.status.on')
: t('settings.status.off')
}
active={settings.startWithWindows}
/>
</div>
</section>
)
}
function StatusPill({
label,
value,
active,
tone = 'success'
}: {
label: string
value: string
active: boolean
tone?: 'success' | 'info'
}): JSX.Element {
const activeClass = tone === 'info' ? 'text-info' : 'text-success'
return (
<div className="rounded-2xl bg-surface-2 px-3.5 py-3 min-w-0">
<div className="text-[12px] text-text/50 font-semibold leading-tight">
{label}
</div>
<div
className={[
'mt-1 text-[14px] font-bold leading-tight break-words',
active ? activeClass : 'text-text/55'
].join(' ')}
>
{value}
</div>
</div>
)
}
function settingsStatusHint(settings: SettingsType, t: TFn): string {
if (!settings.globalEnabled) return t('settings.status.hint.paused')
if (settings.quietHours.enabled) {
return t('settings.status.hint.quiet', {
from: settings.quietHours.from,
to: settings.quietHours.to
})
}
if (!settings.startWithWindows) return t('settings.status.hint.autostart')
return t('settings.status.hint.ready')
}
function DiagnosticsCard(): JSX.Element {
const { t, lang } = useT()
const [info, setInfo] = useState<DiagnosticsInfo | null>(null)
const [busy, setBusy] = useState<'refresh' | 'copy' | 'logs' | null>(null)
const [toast, setToast] = useState<string | null>(null)
const refresh = useCallback(async (): Promise<void> => {
setBusy('refresh')
try {
setInfo(await window.api.getDiagnostics())
} catch {
setToast(translate(lang, 'settings.diagnostics.err'))
} finally {
setBusy(null)
}
}, [lang])
useEffect(() => {
void refresh()
}, [refresh])
useEffect(() => {
if (!toast) return
const id = setTimeout(() => setToast(null), 4000)
return () => clearTimeout(id)
}, [toast])
async function copy(): Promise<void> {
setBusy('copy')
try {
setInfo(await window.api.copyDiagnostics())
setToast(t('settings.diagnostics.copy.ok'))
} catch {
setToast(t('settings.diagnostics.err'))
} finally {
setBusy(null)
}
}
async function openLogs(): Promise<void> {
setBusy('logs')
try {
const r = await window.api.openLogsFolder()
setToast(
r.ok
? t('settings.diagnostics.logs.ok')
: t('settings.diagnostics.logs.err')
)
} catch {
setToast(t('settings.diagnostics.logs.err'))
} finally {
setBusy(null)
}
}
const appLine = info
? `v${info.app.version} · Electron ${info.runtime.electron}`
: t('settings.diagnostics.loading')
const dataLine = info
? `${info.store.exercises}/${info.store.meals}/${info.store.challenges}/${info.store.history}`
: '—'
const gsiLine = info
? `${info.gsi.running ? 'live' : 'off'} · ${info.gsi.baseUrl}`
: '—'
return (
<Card>
<Row>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.diagnostics.app.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{appLine}
</div>
</div>
<Button
type="button"
size="sm"
variant="plain"
onClick={refresh}
disabled={busy !== null}
title={t('settings.diagnostics.refresh')}
aria-label={t('settings.diagnostics.refresh')}
>
<RefreshCw size={16} />
</Button>
</Row>
<Row>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.diagnostics.data.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{dataLine}
</div>
</div>
<div className="text-[12px] text-text/50 font-semibold whitespace-nowrap">
{t('settings.diagnostics.data.legend')}
</div>
</Row>
<Row>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.diagnostics.gsi.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug truncate">
{gsiLine}
</div>
</div>
</Row>
<Row last className="flex-wrap justify-end">
<div className="flex-1 min-w-[180px] text-[13px] text-text/65 leading-snug">
{t('settings.diagnostics.hint')}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant="tinted"
onClick={openLogs}
disabled={busy !== null}
>
<FolderOpen size={16} />
{t('settings.diagnostics.logs.btn')}
</Button>
<Button
type="button"
size="sm"
variant="filled"
onClick={copy}
disabled={busy !== null}
>
<Copy size={16} />
{t('settings.diagnostics.copy.btn')}
</Button>
</div>
</Row>
{toast && (
<div className="px-4 py-2.5 text-[13px] text-text/75 bg-accent/8 truncate font-medium">
{toast}
</div>
)}
</Card>
)
}
function AboutCard(): JSX.Element { function AboutCard(): JSX.Element {
const { t } = useT() const { t } = useT()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [version, setVersion] = useState<string>('')
useEffect(() => {
void window.api.getAppVersion().then(setVersion)
}, [])
// Все версии для которых у нас есть заметки, отсортированы desc. // Все версии для которых у нас есть заметки, отсортированы desc.
const allVersions = Object.keys(RELEASE_NOTES).sort((a, b) => { const allVersions = Object.keys(RELEASE_NOTES).sort((a, b) => {
const pa = a.split('.').map(Number) const pa = a.split('.').map(Number)
@@ -200,6 +543,19 @@ function AboutCard(): JSX.Element {
}) })
return ( return (
<Card> <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> <Row last>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight"> <div className="text-[15px] font-semibold leading-tight">
@@ -227,7 +583,9 @@ function AboutCard(): JSX.Element {
function DataCard(): JSX.Element { function DataCard(): JSX.Element {
const { t } = useT() const { t } = useT()
const [busy, setBusy] = useState(false) // Какая операция сейчас идёт — чтобы крутить спиннер на нужной кнопке,
// а не на обеих сразу.
const [busy, setBusy] = useState<'export' | 'import' | null>(null)
const [toast, setToast] = useState<string | null>(null) const [toast, setToast] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false)
@@ -239,30 +597,32 @@ function DataCard(): JSX.Element {
}, [toast]) }, [toast])
async function onExport(): Promise<void> { async function onExport(): Promise<void> {
setBusy(true) setBusy('export')
try { try {
const r = await window.api.exportState() const r = await window.api.exportState()
if (r.ok && r.path) { if (r.ok && r.path) {
setToast(t('settings.data.export.ok', { path: 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')) setToast(t('settings.data.export.err'))
} }
} finally { } finally {
setBusy(false) setBusy(null)
} }
} }
async function performImport(): Promise<void> { async function performImport(): Promise<void> {
setConfirmOpen(false) setConfirmOpen(false)
setBusy(true) setBusy('import')
try { try {
const r = await window.api.importState() const r = await window.api.importState()
if (r.ok) setToast(t('settings.data.import.ok')) 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')) setToast(t('settings.data.import.err'))
} }
} finally { } finally {
setBusy(false) setBusy(null)
} }
} }
@@ -279,9 +639,10 @@ function DataCard(): JSX.Element {
</div> </div>
<button <button
onClick={onExport} onClick={onExport}
disabled={busy} disabled={busy !== null}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50" 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')} {t('settings.data.export.btn')}
</button> </button>
</Row> </Row>
@@ -296,9 +657,10 @@ function DataCard(): JSX.Element {
</div> </div>
<button <button
onClick={() => setConfirmOpen(true)} onClick={() => setConfirmOpen(true)}
disabled={busy} disabled={busy !== null}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50" 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')} {t('settings.data.import.btn')}
</button> </button>
</Row> </Row>
@@ -363,11 +725,10 @@ function QuietTimesRow({
}): JSX.Element { }): JSX.Element {
const { t } = useT() const { t } = useT()
// Local mirror of from/to so typing doesn't fire an IPC + disk write per // Local mirror of from/to so typing doesn't fire an IPC + disk write per
// keystroke. We commit on blur (or when validation passes during typing). // keystroke. We commit on blur and only send values accepted by the shared
// The HH:MM regex catches the moment the user has typed a full time. // HH:MM parser.
const [from, setFrom] = useState(qh.from) const [from, setFrom] = useState(qh.from)
const [to, setTo] = useState(qh.to) const [to, setTo] = useState(qh.to)
const HHMM = /^\d{1,2}:\d{2}$/
// Sync from props when an external state change happens (lang switch, // Sync from props when an external state change happens (lang switch,
// pause toggle), but only if user isn't mid-edit. // pause toggle), but only if user isn't mid-edit.
@@ -381,7 +742,7 @@ function QuietTimesRow({
const commit = (next: { from?: string; to?: string }): void => { const commit = (next: { from?: string; to?: string }): void => {
const f = next.from ?? from const f = next.from ?? from
const tt = next.to ?? to const tt = next.to ?? to
if (!HHMM.test(f) || !HHMM.test(tt)) return if (parseHHMM(f) === null || parseHHMM(tt) === null) return
if (f === qh.from && tt === qh.to) return if (f === qh.from && tt === qh.to) return
onChange({ ...qh, from: f, to: tt }) onChange({ ...qh, from: f, to: tt })
} }

View File

@@ -230,3 +230,25 @@ body {
.dark .text-tertiary { .dark .text-tertiary {
color: rgb(var(--text-tertiary) / 0.3); 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,10 +8,21 @@ export const IPC = {
snooze: 'exercise:snooze', snooze: 'exercise:snooze',
skip: 'exercise:skip', skip: 'exercise:skip',
// Meals (приёмы пищи — напоминания по времени суток)
addMeal: 'meal:add',
updateMeal: 'meal:update',
deleteMeal: 'meal:delete',
toggleMeal: 'meal:toggle',
markMealDone: 'meal:markDone',
updateSettings: 'settings:update', updateSettings: 'settings:update',
getAccentColor: 'system:accentColor', getAccentColor: 'system:accentColor',
getOsTheme: 'system:osTheme', getOsTheme: 'system:osTheme',
getAppVersion: 'system:appVersion', getAppVersion: 'system:appVersion',
getDiagnostics: 'system:diagnostics',
openLogsFolder: 'system:openLogsFolder',
copyDiagnostics: 'system:copyDiagnostics',
reportRendererError: 'system:reportRendererError',
pauseAll: 'app:pauseAll', pauseAll: 'app:pauseAll',
resumeAll: 'app:resumeAll', resumeAll: 'app:resumeAll',
@@ -60,6 +71,7 @@ export const IPC = {
// events from main → renderer // events from main → renderer
evtTick: 'evt:tick', evtTick: 'evt:tick',
evtFire: 'evt:fire', evtFire: 'evt:fire',
evtFireMeal: 'evt:fireMeal',
evtMatchEnd: 'evt:matchEnd', evtMatchEnd: 'evt:matchEnd',
evtStateChanged: 'evt:stateChanged', evtStateChanged: 'evt:stateChanged',
evtThemeChanged: 'evt:themeChanged', evtThemeChanged: 'evt:themeChanged',
@@ -68,6 +80,15 @@ export const IPC = {
evtUpdaterStatus: 'evt:updaterStatus', evtUpdaterStatus: 'evt:updaterStatus',
evtMaximizeChanged: 'evt:maximizeChanged', evtMaximizeChanged: 'evt:maximizeChanged',
evtMeetingChanged: 'evt:meetingChanged', evtMeetingChanged: 'evt:meetingChanged',
/**
* Шлётся когда история мутирует (markDone / markMealDone / snooze / skip /
* markChallengeDone / clearHistory / import). Renderer'у достаточно
* перезапросить getHistory. Раньше Dashboard переключал history по
* `exercises` ref'у — но markDone мутирует Exercise in place, ref не
* меняется, и heatmap стояла. Этот event — единый сигнал что надо
* перетянуть.
*/
evtHistoryChanged: 'evt:historyChanged',
getMeetingActive: 'system:meetingActive' getMeetingActive: 'system:meetingActive'
} as const } 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,248 @@ export type ReleaseNoteItem = {
export type ReleaseNotes = Record<Language, ReleaseNoteItem[]> export type ReleaseNotes = Record<Language, ReleaseNoteItem[]>
export const RELEASE_NOTES: Record<string, ReleaseNotes> = { export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
'0.6.6': {
ru: [
{
title: 'Настройки стали панелью состояния',
detail:
'Сверху видно, работают ли напоминания, включены ли тихие часы, встречи и запуск вместе с Windows.',
tag: 'new'
},
{
title: 'Главный экран стал точнее по текстам',
detail:
'Дата больше не кричит заглавными буквами, а цели показывают понятные единицы: “осталось 30 раз”.',
tag: 'fix'
},
{
title: 'Сводные карточки читаются лучше',
detail:
'Длинные значения в карточках больше не обрезаются, а сетка спокойнее держит десктопные ширины.',
tag: 'fix'
},
{
title: 'Безопасный стенд для проверки интерфейса',
detail:
'Добавлен dev:renderer: можно открыть UI в браузере с демо-данными и не трогать реальные настройки.',
tag: 'new'
}
],
en: [
{
title: 'Settings now show app status first',
detail:
'The top panel shows whether reminders, quiet hours, meeting pause and Windows autostart are active.',
tag: 'new'
},
{
title: 'Overview copy is clearer',
detail:
'The date no longer gets artificial capitalization, and goals show units like “30 reps left”.',
tag: 'fix'
},
{
title: 'Summary cards are easier to read',
detail:
'Long values no longer get clipped, and the card grid behaves better on desktop widths.',
tag: 'fix'
},
{
title: 'Safe renderer preview for UI checks',
detail:
'Added dev:renderer so the UI can be opened with demo data without touching real settings.',
tag: 'new'
}
]
},
'0.6.5': {
ru: [
{
title: 'Главный экран стал обзором действий',
detail:
'Верхний заголовок теперь показывает состояние: что сделать сейчас, что ждёт, пауза или встреча.',
tag: 'new'
},
{
title: 'Исправлен запуск с Windows',
detail:
'Проверка автозапуска теперь использует тот же путь и аргументы, что и запись в Windows.',
tag: 'fix'
},
{
title: 'Discord больше не ставит перерывы на паузу',
detail:
'Авто-пауза встреч реагирует на Zoom, Teams, Webex и Slack-huddle, но не на обычный Discord.',
tag: 'fix'
}
],
en: [
{
title: 'The main screen is now an action overview',
detail:
'The header shows the current state: what to do now, what is due, pause or meeting.',
tag: 'new'
},
{
title: 'Fixed Start with Windows',
detail:
'Autostart now reads Windows login items with the same path and arguments it writes.',
tag: 'fix'
},
{
title: 'Discord no longer pauses breaks',
detail:
'Meeting auto-pause still handles Zoom, Teams, Webex and Slack-huddle, but ignores Discord.',
tag: 'fix'
}
]
},
'0.6.4': {
ru: [
{
title: 'Новый видимый бренд “Разомнись”',
detail:
'Название стало короче и понятнее для русской аудитории: действие видно сразу.',
tag: 'new'
},
{
title: 'Сводки на каждом экране',
detail:
'Упражнения, питание, игры, челленджи и настройки теперь быстрее сканируются сверху.',
tag: 'new'
},
{
title: 'Улучшена структура вторичных страниц',
detail:
'Важные статусы вынесены выше списков, чтобы сразу видеть активность и проблемные места.',
tag: 'new'
}
],
en: [
{
title: 'New visible “Razomnis” brand',
detail:
'The name is shorter and more action-focused for the Russian audience.',
tag: 'new'
},
{
title: 'Overview cards on every screen',
detail:
'Exercises, meals, games, challenges and settings are easier to scan from the top.',
tag: 'new'
},
{
title: 'Secondary pages are more structured',
detail:
'Important statuses moved above long lists so active and risky areas are visible immediately.',
tag: 'new'
}
]
},
'0.6.3': {
ru: [
{
title: 'Новый главный экран “Не Залипай”',
detail:
'Сегодня теперь показывает не только план, но и недельный ритм, уровень и игровые долги.',
tag: 'new'
},
{
title: 'Мини-челленджи недели',
detail:
'5 дней без нуля, 1000 повторов, “сегодня не ноль” и закрытые катки считаются автоматически.',
tag: 'new'
},
{
title: 'Игровой долг после каток',
detail:
'На Dashboard видно, сколько Dota-долгов закрыто сегодня и за неделю.',
tag: 'new'
},
{
title: 'Мягкая система уровней',
detail:
'XP считается из повторов, активных дней и закрытых игровых челленджей.',
tag: 'new'
}
],
en: [
{
title: 'New “Ne Zalipay” Today screen',
detail:
'Today now shows the day plan, weekly rhythm, level and game debts.',
tag: 'new'
},
{
title: 'Weekly mini-challenges',
detail:
'5 non-zero days, 1000 reps, “today is not zero” and closed matches are tracked automatically.',
tag: 'new'
},
{
title: 'Game debt after matches',
detail:
'Dashboard shows how many Dota debts were closed today and this week.',
tag: 'new'
},
{
title: 'Soft level system',
detail:
'XP comes from reps, active days and completed game challenges.',
tag: 'new'
}
]
},
'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': { '0.5.7': {
ru: [ ru: [
{ {
@@ -47,7 +289,7 @@ export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
{ {
title: 'Видно когда мы молчим из-за ВКС', title: 'Видно когда мы молчим из-за ВКС',
detail: detail:
'Запущен Zoom/Teams — на Dashboard баннер «Не дёргаем — ты на встрече».', 'Запущен Zoom/Teams — на Dashboard появляется баннер активной встречи.',
tag: 'new' tag: 'new'
}, },
{ {
@@ -130,7 +372,7 @@ export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
{ {
title: 'Авто-пауза на ВКС', title: 'Авто-пауза на ВКС',
detail: detail:
'Не дёргает напоминаниями, если запущен Zoom/Teams/Discord/Webex/Slack-huddle.', 'Ставит напоминания на паузу, если запущен Zoom/Teams/Webex/Slack-huddle.',
tag: 'new' tag: 'new'
}, },
{ {
@@ -154,14 +396,12 @@ export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
en: [ en: [
{ {
title: 'Reminder categories', title: 'Reminder categories',
detail: detail: 'Beyond exercises — hydration, eye rest (20-20-20), posture.',
'Beyond exercises — hydration, eye rest (20-20-20), posture.',
tag: 'new' tag: 'new'
}, },
{ {
title: 'Voice prompts', title: 'Voice prompts',
detail: detail: 'Speaks the exercise name and count. Toggle in Settings.',
'Speaks the exercise name and count. Toggle in Settings.',
tag: 'new' tag: 'new'
}, },
{ {
@@ -179,7 +419,7 @@ export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
{ {
title: 'Meeting auto-pause', title: 'Meeting auto-pause',
detail: detail:
'No reminders while Zoom/Teams/Discord/Webex/Slack-huddle is running.', 'Pauses reminders while Zoom/Teams/Webex/Slack-huddle is running.',
tag: 'new' tag: 'new'
}, },
{ {
@@ -205,7 +445,8 @@ export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
ru: [ ru: [
{ {
title: 'Sandbox для окон', title: 'Sandbox для окон',
detail: 'Окна изолированы на уровне OS — даже RCE в рендере не достанет main.', detail:
'Окна изолированы на уровне OS — даже RCE в рендере не достанет main.',
tag: 'security' tag: 'security'
}, },
{ {
@@ -232,7 +473,8 @@ export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
en: [ en: [
{ {
title: 'Window sandbox', title: 'Window sandbox',
detail: 'OS-level isolation — even RCE in the renderer cannot reach main.', detail:
'OS-level isolation — even RCE in the renderer cannot reach main.',
tag: 'security' tag: 'security'
}, },
{ {
@@ -261,24 +503,28 @@ export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
ru: [ ru: [
{ {
title: 'Фоновое скачивание апдейта', title: 'Фоновое скачивание апдейта',
detail: 'Можно уйти на Dashboard и заниматься — апдейт качается в фоне.', detail:
'Можно уйти на Dashboard и заниматься — апдейт качается в фоне.',
tag: 'new' tag: 'new'
}, },
{ {
title: 'Моментальный рестарт', title: 'Моментальный рестарт',
detail: 'Кнопка «Рестарт» — ~1-2 сек до открытия новой версии, без диалогов NSIS.', detail:
'Кнопка «Рестарт» — ~1-2 сек до открытия новой версии, без диалогов NSIS.',
tag: 'new' tag: 'new'
} }
], ],
en: [ en: [
{ {
title: 'Background update download', title: 'Background update download',
detail: 'You can go to Dashboard and work — the update keeps downloading.', detail:
'You can go to Dashboard and work — the update keeps downloading.',
tag: 'new' tag: 'new'
}, },
{ {
title: 'Instant restart', title: 'Instant restart',
detail: 'Restart button — ~1-2 sec to the new version, no NSIS dialogs.', detail:
'Restart button — ~1-2 sec to the new version, no NSIS dialogs.',
tag: 'new' tag: 'new'
} }
] ]
@@ -302,7 +548,9 @@ export function unseenVersions(
// явный «What's new» из Settings. // явный «What's new» из Settings.
return all.filter((v) => v === current) return all.filter((v) => v === current)
} }
return all.filter((v) => compareSemver(v, lastSeen) > 0 && compareSemver(v, current) <= 0) return all.filter(
(v) => compareSemver(v, lastSeen) > 0 && compareSemver(v, current) <= 0
)
} }
function parseSemver(v: string): [number, number, number] { function parseSemver(v: string): [number, number, number] {

View File

@@ -42,6 +42,40 @@ export type Exercise = {
adaptive?: boolean 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 NotificationMode = 'toast' | 'modal' | 'both'
export type Theme = 'light' | 'dark' | 'system' export type Theme = 'light' | 'dark' | 'system'
export type Language = 'ru' | 'en' export type Language = 'ru' | 'en'
@@ -71,7 +105,7 @@ export type Settings = {
voicePromptsEnabled: boolean voicePromptsEnabled: boolean
/** /**
* Авто-пауза напоминаний во время ВКС-звонков. Сканирует список процессов * Авто-пауза напоминаний во время ВКС-звонков. Сканирует список процессов
* (Zoom/Teams/Discord/Webex/Slack-huddle/etc) раз в 30 сек, если хоть один * (Zoom/Teams/Webex/Slack-huddle/etc) раз в 30 сек, если хоть один
* запущен — fires не происходят. Чисто Windows (через tasklist). * запущен — fires не происходят. Чисто Windows (через tasklist).
*/ */
meetingAutoPause: boolean meetingAutoPause: boolean
@@ -99,6 +133,7 @@ export type Settings = {
*/ */
export type AppState = { export type AppState = {
exercises: Exercise[] exercises: Exercise[]
meals: Meal[]
settings: Settings settings: Settings
challenges: Challenge[] challenges: Challenge[]
gamesEnabled: Partial<Record<GameId, boolean>> gamesEnabled: Partial<Record<GameId, boolean>>
@@ -112,10 +147,11 @@ export type PersistedState = AppState & {
export type HistoryAction = 'done' | 'skip' | 'snooze' export type HistoryAction = 'done' | 'skip' | 'snooze'
/** /**
* Источник записи: обычное напоминание (от scheduler'а) или матч (челлендж). * Источник записи: обычное напоминание (от scheduler'а), приём пищи или
* матч (челлендж).
* Используется для UI («подтянулся в матче» vs «по таймеру») и аналитики. * Используется для UI («подтянулся в матче» vs «по таймеру») и аналитики.
*/ */
export type HistorySource = 'reminder' | 'match' export type HistorySource = 'reminder' | 'meal' | 'match'
export type HistoryEntry = { export type HistoryEntry = {
/** ms epoch */ /** ms epoch */
@@ -264,7 +300,7 @@ export const DEFAULT_SETTINGS: Settings = {
const HHMM_RE = /^(\d{1,2}):(\d{2})$/ const HHMM_RE = /^(\d{1,2}):(\d{2})$/
/** Parse `HH:MM` into minutes-since-midnight, or `null` if malformed. */ /** Parse `HH:MM` into minutes-since-midnight, or `null` if malformed. */
function parseHHMM(s: string): number | null { export function parseHHMM(s: string): number | null {
const m = HHMM_RE.exec(s) const m = HHMM_RE.exec(s)
if (!m) return null if (!m) return null
const h = Number(m[1]) const h = Number(m[1])
@@ -314,6 +350,37 @@ export function isQuietAt(qh: QuietHours, now: Date): boolean {
return false 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'>[] = [ export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
{ {
name: 'Приседания', name: 'Приседания',
@@ -357,6 +424,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 = export type UpdaterStatus =
| { kind: 'idle'; lastCheckedAt?: number } | { kind: 'idle'; lastCheckedAt?: number }
| { kind: 'unsupported'; reason: string } | { kind: 'unsupported'; reason: string }
@@ -372,3 +455,45 @@ export type UpdaterStatus =
} }
| { kind: 'downloaded'; version: string } | { kind: 'downloaded'; version: string }
| { kind: 'error'; message: string } | { kind: 'error'; message: string }
export type DiagnosticsInfo = {
generatedAt: number
app: {
version: string
isPackaged: boolean
platform: string
arch: string
}
runtime: {
electron: string
chrome: string
node: string
}
paths: {
userData: string
store: string
logs: string
}
store: {
bytes: number | null
exercises: number
meals: number
challenges: number
history: number
}
updater: UpdaterStatus
games: GameStatus[]
gsi: {
running: boolean
port: number
baseUrl: string
}
meetingActive: boolean
}
export type RendererErrorReport = {
message: string
stack?: string
componentStack?: string
source?: string
}

19
vite.renderer.config.mjs Normal file
View File

@@ -0,0 +1,19 @@
import { resolve } from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
root: resolve('src/renderer'),
resolve: {
alias: {
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared')
}
},
plugins: [react()],
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true
}
})