22 Commits

Author SHA1 Message Date
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
AnRil
c742417e82 chore(release): v0.5.7 2026-05-22 15:27:38 +07:00
AnRil
71b664d284 docs(v0.5.7): CHANGELOG + release-notes + badge 2026-05-22 15:27:28 +07:00
AnRil
db18d0c512 fix(P2): celebration, TTS delay, match-close confirm, tracking badge
P2 #9 — Achievement unlock celebration. AchievementsCard сравнивает
    текущие unlocked с persisted set'ом в localStorage. Новые играют
    scale+accent-glow анимацию 2.8 сек (1.4с × 2 повтора). После
    добавляются в celebrated — при следующем заходе не подпрыгивают.

P2 #10 — Match summary close confirm. Если в Match Summary остались
    незакрытые челленджи, при close через X / Esc / btn — native
    confirm с количеством остатка. Пользователь не «пролетает» окно
    случайно.

P2 #11 — TTS delay 800ms. Дикторский голос ждёт 800мс прежде чем
    проговорить — пользователь успевает decrement stepper если хочет
    сделать меньше планового. Иначе голос произносит планируемые
    reps уже когда юзер изменил цифру.

P2 #13 — Tracking badge точность. Раньше зелёный если просто
    `gamesEnabled[id]`. Теперь зелёный только если live (enabled +
    integrationActive + launchOptionStatus='applied'). Введён tone
    'warning' для «включена, но launch option ещё queued» с подсказкой
    «закрой Steam и снова открой». Dashboard подписывается на
    onGamesChanged + первый listGames на mount.
2026-05-22 15:15:41 +07:00
AnRil
9c989612fe fix(P1): delete-confirm, daily-goal closed UI, meeting indicator, modal-confirm
P1 #4 — ConfirmModal (новый src/renderer/src/components/ui/ConfirmModal.tsx)
    с iOS-стилем + focus-trap (через Modal). Delete упражнения в Dashboard
    теперь спрашивает «Удалить упражнение?» с destructive-кнопкой.

P1 #5 — Daily goal closed UI. ExerciseCard принимает doneToday prop
    и при `done >= dailyGoal` показывает «Цель закрыта · 100/100»
    вместо запутанного «25ч 13м» countdown'а. Цвет — success-зелёный.

P1 #6 — Meeting auto-pause indicator. Новый IPC.getMeetingActive +
    evtMeetingChanged event. meeting-detect broadcast'ит изменения
    состояния. Dashboard показывает info-баннер «Не дёргаем — ты на
    встрече» когда meetingAutoPause включён и хотя бы один meeting
    процесс запущен.

P1 #7 — Native window.confirm() заменён на ConfirmModal в Settings
    DataCard для restore-операции. Теперь iOS-style с destructive
    confirm-кнопкой и focus-trap'ом.

Заодно P2 #8: Brain-иконка-badge на ExerciseCard для adaptive
    упражнений — пользователь видит почему «Next» не строго равен
    intervalMinutes.
P2 #12: dailyReps/dailyRepsRange/totalDoneReps/repsDoneTodayForExercise
    используют entry.reps как fallback — heatmap не теряет данные
    после удаления упражнения.
2026-05-22 15:06:25 +07:00
AnRil
17df87b3aa fix(P0): match-history, tray/dashboard pause sync, whatsnew для upgraders
P0 #1 — Match-челленджи теперь пишутся в историю.
    HistoryEntry расширен полями `reps?`, `name?`, `source?` (snapshot
    planned-reps + name на момент записи + 'reminder'/'match').
    Новый store.markChallengeDone(challengeId, reps) пишет entry с
    exerciseId='challenge:<id>' и source='match'.
    Зарегистрирован IPC.markChallengeDone handler (раньше канал был в
    enum, но handler не подключен).
    ReminderApp.MatchSummaryView вызывает window.api.markChallengeDone
    при ✓-клике. Стрик, today_done, achievements теперь учитывают
    игровые тренировки.
    Заодно dailyReps/dailyRepsRange/totalDoneReps используют
    entry.reps как fallback — heatmap не теряет данные после удаления
    упражнения (закрывает P2 #12).

P0 #2 — Tray-пауза синхронизирована с Dashboard.
    Раньше tray держал scheduler-local `paused` boolean, который не
    отражался в settings.globalEnabled — Dashboard показывал «running»
    с тикающим таймером, хотя fires не приходили. Сейчас оба пути
    (tray и Dashboard-кнопка) меняют единственный source of truth —
    settings.globalEnabled. setPaused/isPaused/paused удалены, IPC
    pauseAll/resumeAll переписаны на updateSettings.

P0 #3 — Whats-new покажется существующим пользователям при апгрейде.
    Раньше для всех undefined lastSeenVersion (включая обновляющихся
    с v0.5.5) делали silent-save без модалки — никто бы не увидел
    v0.5.6 changelog. Сейчас: если есть Exercise с lastDoneAt → это
    обновляющийся пользователь, показываем заметки текущей версии;
    если нет — новичок, silent.
2026-05-22 14:49:29 +07:00
70 changed files with 8144 additions and 2996 deletions

View File

@@ -6,6 +6,137 @@
## [Unreleased] ## [Unreleased]
## [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
Сквозное ревью UX: пройдено 12 сценариев глазами пользователя, найдено
13 проблем (3 настоящих бага P0 + 4 UX-просадки P1 + 6 мелочей P2),
все починены.
### Fixed (P0 — функциональные баги)
- **Match-челленджи теперь пишутся в историю.** Раньше клик ✓ в окне
Match Summary обновлял только локальный `Set<challengeId>` — челленджи
не доходили до store, и стрик / today_done / достижения игнорировали
игровые тренировки (самую главную фишку приложения). Сейчас IPC
`markChallengeDone` пишет entry с `source='match'`,
`exerciseId='challenge:<id>'`, `actualReps`, `reps` (snapshot).
- **Tray-пауза синхронизирована с Dashboard.** Раньше «Пауза напоминаний»
из tray использовала scheduler-local `paused` boolean, который не
отражался в `settings.globalEnabled` — Dashboard показывал «running»
с тикающим таймером, хотя fires не приходили. Сейчас единый
source of truth — `settings.globalEnabled`. `setPaused/isPaused`
удалены, IPC `pauseAll/resumeAll` переписаны на updateSettings.
- **WhatsNew покажется обновляющимся пользователям.** В v0.5.6 логика
была: `lastSeenVersion === undefined` → silent save (не показывать).
Это означало что **никто** из текущих пользователей при апгрейде с
v0.5.5 не увидит описание новых фич. Сейчас: если есть Exercise
с `lastDoneAt` — пользователь существующий, показываем заметки
текущей версии. Иначе (новичок) — silent.
### Fixed (P1 — UX просадки)
- **Удаление упражнения теперь спрашивает подтверждение.** Раньше
один клик в menu «Удалить» сразу удалял. Сейчас iOS-style ConfirmModal
с destructive-кнопкой.
- **Daily goal закрыт — больше не «25 часов 13 минут».** Когда дневная
цель достигнута, ExerciseCard показывает «Цель закрыта · 100/100»
с success-зелёным цветом, а не запутанный обратный отсчёт до завтра.
- **Авто-пауза на ВКС видна в Dashboard.** Раньше fires пропускались
молча — пользователь не понимал почему через 12 мин ничего не пришло.
Сейчас info-баннер «Не дёргаем — ты на встрече» с указанием закрыть
Zoom/Teams/etc.
- **Native `window.confirm()` → iOS-style ConfirmModal** в restore-операции.
Раньше всплывал серый системный диалог.
### Fixed (P2 — полировка)
- **Achievement unlock celebration.** Когда впервые открылся новый
badge — pulse + accent-glow анимация 2.8 сек. Список celebrated
хранится в localStorage, при следующем заходе анимации нет.
- **Match Summary close confirm.** При закрытии окна с незакрытыми
челленджами спрашиваем подтверждение с указанием остатка.
- **TTS задержка 800мс.** Дикторский голос даёт пользователю шанс
decrement'нуть stepper — иначе TTS произносил планируемые reps в тот
момент когда юзер уже изменил цифру.
- **Tracking badge точность.** Раньше зелёный показывался даже при
`queued` launch option. Сейчас 3 состояния: `live` (success, всё
работает), `setup` (warning «закрой Steam и снова открой» если launch
option ещё не применён), `off` (muted, не подключено).
- **HistoryEntry хранит snapshot `reps`+`name`+`source`.** Heatmap и
achievements больше не теряют данные после удаления упражнения.
- **Adaptive-badge на ExerciseCard.** Маленькая Brain-иконка показывает,
что упражнение в адаптивном режиме — пользователь понимает почему
Next не строго равен intervalMinutes.
### Added
- `src/renderer/src/components/ui/ConfirmModal.tsx` — переиспользуемый
iOS-style confirm с focus-trap'ом через Modal.
- IPC `markChallengeDone(challengeId, reps)` — handler в main, метод в
preload (раньше канал был в IPC enum, handler не зарегистрирован).
- IPC `getMeetingActive` + event `evtMeetingChanged` — meeting-detect
broadcast'ит при переходе on/off.
- Helper `repsDoneTodayForExercise(history, exercise)` —
per-exercise daily count для UI индикатора goal.
## [0.5.6] — 2026-05-22 ## [0.5.6] — 2026-05-22
Большой релиз с 7 новыми фичами + экран «Что нового», который покажется Большой релиз с 7 новыми фичами + экран «Что нового», который покажется
@@ -343,13 +474,16 @@
иконки), системный трей, автозапуск с 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.6...HEAD [Unreleased]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.2...HEAD
[0.5.6]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.6 [0.6.2]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.5.8...v0.6.2
[0.5.5]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.5 [0.5.8]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.8
[0.5.4]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.4 [0.5.7]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.7
[0.5.3]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.3 [0.5.6]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.6
[0.5.2]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.2 [0.5.5]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.5
[0.5.1]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.1 [0.5.4]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.4
[0.5.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.0 [0.5.3]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.3
[0.4.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.4.0 [0.5.2]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/tag/v0.5.2
[0.2.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.2.0 [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.6**. Один разработчик (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

@@ -2,13 +2,14 @@
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений. Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
[![release](https://img.shields.io/badge/release-v0.5.6-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest) [![release](https://img.shields.io/badge/release-v0.6.1-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-238%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, в историю запишется честное число.
@@ -34,7 +35,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,6 +45,7 @@ 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 build # сборка без NSIS npm run build # сборка без NSIS
@@ -55,8 +57,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 +68,34 @@ 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 (7)
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)
──────────────────────────────────────────
238 ✓
``` ```
Покрытие: чистые 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,6 +1,6 @@
{ {
"name": "laude", "name": "laude",
"version": "0.5.6", "version": "0.6.2",
"description": "Exercise reminder — Windows desktop app", "description": "Exercise reminder — Windows desktop app",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "AnRil", "author": "AnRil",
@@ -18,6 +18,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 +29,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 +43,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 +57,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 +104,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()
)
})
})

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,31 +7,42 @@ 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,
getStateForRenderer, getStateForRenderer,
importState, importState,
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 { setPaused, forceCheck } from './scheduler' import { forceCheck } from './scheduler'
import { hideReminderWindow, getMainWindow } from './windows' import { hideReminderWindow, getMainWindow } from './windows'
import { refreshMenu } from './tray' import { refreshMenu } from './tray'
import { import {
@@ -48,6 +59,7 @@ import {
getUpdaterStatus, getUpdaterStatus,
quitAndInstall quitAndInstall
} from './updater' } from './updater'
import { isMeetingActiveSync } from './meeting-detect'
import { import {
validateActualReps, validateActualReps,
validateChallengeInput, validateChallengeInput,
@@ -55,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()
@@ -71,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)
@@ -79,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)
@@ -99,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) {
@@ -152,22 +281,27 @@ export function registerIpc(): void {
} }
const settings = updateSettings(merged) const settings = updateSettings(merged)
broadcastState() broadcastState()
// Language change reflects in the tray menu next time it's opened. // Tray-menu label «Пауза/Возобновить» зависит от globalEnabled — рефреш.
if (patch.language !== undefined) refreshMenu() // А также language change.
if (patch.language !== undefined || patch.globalEnabled !== undefined) {
refreshMenu()
}
return settings return settings
}) })
ipcMain.handle(IPC.pauseAll, () => { safeHandle(IPC.pauseAll, () => {
setPaused(true) updateSettings({ globalEnabled: false })
broadcastState()
refreshMenu() refreshMenu()
}) })
ipcMain.handle(IPC.resumeAll, () => { safeHandle(IPC.resumeAll, () => {
setPaused(false) updateSettings({ globalEnabled: true })
broadcastState()
forceCheck() forceCheck()
refreshMenu() refreshMenu()
}) })
ipcMain.handle(IPC.getAccentColor, () => { safeHandle(IPC.getAccentColor, () => {
try { try {
return '#' + systemPreferences.getAccentColor() return '#' + systemPreferences.getAccentColor()
} catch { } catch {
@@ -175,43 +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.quit, () => app.quit()) safeHandle(IPC.getMeetingActive, () => isMeetingActiveSync())
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
ipcMain.on(IPC.minimizeMain, (event) => { safeHandle(IPC.getDiagnostics, () => getDiagnosticsInfo())
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)
@@ -221,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()
@@ -230,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()
@@ -238,55 +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())
safeHandle(IPC.markChallengeDone, (_e, idRaw: unknown, repsRaw: unknown) => {
const id = validateId(idRaw)
const reps = validateActualReps(repsRaw)
if (!id || reps === undefined || reps <= 0) return false
markChallengeDone(id, reps)
broadcastState()
broadcastHistoryChanged()
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)
@@ -295,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,115 @@
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('discord.exe') })
const { isMeetingActive } = await load()
await isMeetingActive()
await isMeetingActive()
expect(h.calls).toBe(1)
})
it('при падении tasklist возвращает false и логирует warn', async () => {
h.execImpl = (_c, _o, cb) => cb(new Error('ETIMEDOUT'))
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(false)
expect(h.log.warn).toHaveBeenCalled()
})
it('isMeetingActiveSync отражает последний известный результат', async () => {
h.execImpl = (_c, _o, cb) => cb(null, { stdout: csv('webex.exe') })
const mod = await load()
expect(mod.isMeetingActiveSync()).toBe(false) // до первого запроса
await mod.isMeetingActive()
expect(mod.isMeetingActiveSync()).toBe(true)
})
it('на не-Windows возвращает false без вызова tasklist', async () => {
setPlatform('linux')
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(false)
expect(h.calls).toBe(0)
})
})

View File

@@ -16,8 +16,16 @@
*/ */
import { exec } from 'node:child_process' import { exec } from 'node:child_process'
import { promisify } from 'node:util' import { promisify } from 'node:util'
import { BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc'
import { log } from './logger' import { log } from './logger'
function broadcast(active: boolean): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtMeetingChanged, active)
}
}
const execAsync = promisify(exec) const execAsync = promisify(exec)
/** /**
@@ -56,7 +64,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) {
@@ -65,6 +78,7 @@ export async function isMeetingActive(): Promise<boolean> {
if (lower.includes(`"${proc}",`)) { if (lower.includes(`"${proc}",`)) {
if (!cachedActive) { if (!cachedActive) {
log.info(`[meeting] detected ${proc} — pausing reminders`) log.info(`[meeting] detected ${proc} — pausing reminders`)
broadcast(true)
} }
cachedActive = true cachedActive = true
return true return true
@@ -72,6 +86,7 @@ export async function isMeetingActive(): Promise<boolean> {
} }
if (cachedActive) { if (cachedActive) {
log.info('[meeting] no meeting processes — resuming reminders') log.info('[meeting] no meeting processes — resuming reminders')
broadcast(false)
} }
cachedActive = false cachedActive = false
return false return false

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
} }
@@ -36,10 +44,8 @@ const CHECK_MS = 5000
let tickHandle: NodeJS.Timeout | null = null let tickHandle: NodeJS.Timeout | null = null
let powerListenersArmed = false let powerListenersArmed = false
let lastCheckAt = 0 let lastCheckAt = 0
let paused = false
function checkDueExercises(): void { function checkDueExercises(): void {
if (paused) return
const settings = getSettings() const settings = getSettings()
if (!settings.globalEnabled) return if (!settings.globalEnabled) return
@@ -97,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) => ({
@@ -115,6 +154,7 @@ function tick(): void {
if (now - lastCheckAt >= CHECK_MS) { if (now - lastCheckAt >= CHECK_MS) {
lastCheckAt = now lastCheckAt = now
checkDueExercises() checkDueExercises()
checkDueMeals()
} }
} }
@@ -148,14 +188,6 @@ export function stopScheduler(): void {
} }
} }
export function setPaused(value: boolean): void {
paused = value
}
export function isPaused(): boolean {
return paused
}
export function forceCheck(): void { export function forceCheck(): void {
lastCheckAt = 0 lastCheckAt = 0
tick() tick()

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] : []
})
: []
} }
} }
@@ -199,20 +363,32 @@ function load(): PersistedState {
return coerce(runMigrations(parsed)) return coerce(runMigrations(parsed))
} }
type AppendOpts = {
actualReps?: number
/** Planned reps snapshot — иначе после удаления упражнения теряем reps. */
reps?: number
/** Snapshot названия — для будущего log-view (необязательно). */
name?: string
/** 'reminder' (default) или 'match'. */
source?: import('@shared/types').HistorySource
}
function appendHistory( function appendHistory(
exerciseId: string, exerciseId: string,
action: HistoryAction, action: HistoryAction,
actualReps?: number opts: AppendOpts = {}
): void { ): void {
const state = getState() const state = getState()
if (!state.history) state.history = [] if (!state.history) state.history = []
const entry: HistoryEntry = { ts: Date.now(), exerciseId, action } const entry: HistoryEntry = { ts: Date.now(), exerciseId, action }
if (actualReps !== undefined) entry.actualReps = actualReps if (opts.actualReps !== undefined) entry.actualReps = opts.actualReps
if (opts.reps !== undefined) entry.reps = opts.reps
if (opts.name !== undefined) entry.name = opts.name
if (opts.source !== undefined) entry.source = opts.source
state.history.push(entry) state.history.push(entry)
if (state.history.length > HISTORY_MAX) { if (state.history.length > HISTORY_MAX) {
state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9)) state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9))
} }
// Caller schedules the write; appendHistory itself is internal.
} }
export function getHistory(sinceMs?: number): HistoryEntry[] { export function getHistory(sinceMs?: number): HistoryEntry[] {
@@ -291,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)
@@ -348,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
@@ -425,7 +602,12 @@ export function markDone(
if (!ex) return undefined if (!ex) return undefined
ex.lastDoneAt = Date.now() ex.lastDoneAt = Date.now()
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
appendHistory(id, 'done', actualReps) appendHistory(id, 'done', {
actualReps,
reps: ex.reps,
name: ex.name,
source: 'reminder'
})
scheduleWrite() scheduleWrite()
return ex return ex
} }
@@ -435,7 +617,7 @@ export function snooze(id: string, minutes: number): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id) const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined if (!ex) return undefined
ex.nextFireAt = Date.now() + minutes * 60_000 ex.nextFireAt = Date.now() + minutes * 60_000
appendHistory(id, 'snooze') appendHistory(id, 'snooze', { reps: ex.reps, name: ex.name })
scheduleWrite() scheduleWrite()
return ex return ex
} }
@@ -445,11 +627,101 @@ export function skip(id: string): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id) const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined if (!ex) return undefined
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
appendHistory(id, 'skip') appendHistory(id, 'skip', { reps: ex.reps, name: ex.name })
scheduleWrite() scheduleWrite()
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 в историю. Не привязано
* к конкретному Exercise (челлендж может ссылаться на упражнение, которое
* пользователь даже не создал). Используем синтетический id 'challenge:<id>'.
*/
export function markChallengeDone(challengeId: string, reps: number): void {
const state = getState()
const ch = state.challenges.find((c) => c.id === challengeId)
appendHistory(`challenge:${challengeId}`, 'done', {
actualReps: reps,
reps,
name: ch?.exerciseName ?? ch?.name,
source: 'match'
})
scheduleWrite()
}
export function flushNow(): void { export function flushNow(): void {
if (pendingWrite) { if (pendingWrite) {
clearTimeout(pendingWrite) clearTimeout(pendingWrite)
@@ -527,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

@@ -1,9 +1,9 @@
import { Tray, Menu, nativeImage, app } from 'electron' import { Tray, Menu, nativeImage, app } from 'electron'
import { join } from 'node:path' import { join } from 'node:path'
import { showMainWindow } from './windows' import { showMainWindow } from './windows'
import { isPaused, setPaused, forceCheck } from './scheduler' import { forceCheck } from './scheduler'
import { snoozeAll } from './state-actions' import { broadcastState, snoozeAll } from './state-actions'
import { getSettings } from './store' import { getSettings, updateSettings } from './store'
import type { Language } from '@shared/types' import type { Language } from '@shared/types'
let tray: Tray | null = null let tray: Tray | null = null
@@ -69,16 +69,21 @@ export function createTray(): Tray {
export function refreshMenu(): void { export function refreshMenu(): void {
if (!tray) return if (!tray) return
const paused = isPaused() // Single source of truth — settings.globalEnabled. Раньше tray держал
// отдельный scheduler-local `paused` flag, который не синхронизировался
// с Dashboard'ом (там кнопка читает globalEnabled). Теперь оба пути
// правят одно поле.
const paused = !getSettings().globalEnabled
const menu = Menu.buildFromTemplate([ const menu = Menu.buildFromTemplate([
{ label: trayLabel('open'), click: () => showMainWindow() }, { label: trayLabel('open'), click: () => showMainWindow() },
{ type: 'separator' }, { type: 'separator' },
{ {
label: paused ? trayLabel('resume') : trayLabel('pause'), label: paused ? trayLabel('resume') : trayLabel('pause'),
click: () => { click: () => {
setPaused(!paused) updateSettings({ globalEnabled: paused }) // toggle
broadcastState() // чтобы Dashboard перерисовал кнопку сразу
refreshMenu() refreshMenu()
if (!paused) forceCheck() if (paused) forceCheck() // resuming — догнать пропущенные fires
} }
}, },
{ {

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),
@@ -48,6 +64,16 @@ const api = {
getOsTheme: (): Promise<'light' | 'dark'> => getOsTheme: (): Promise<'light' | 'dark'> =>
ipcRenderer.invoke(IPC.getOsTheme), ipcRenderer.invoke(IPC.getOsTheme),
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.getAppVersion), getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.getAppVersion),
getMeetingActive: (): Promise<boolean> =>
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),
@@ -83,6 +109,8 @@ const api = {
ipcRenderer.invoke(IPC.deleteChallenge, id), ipcRenderer.invoke(IPC.deleteChallenge, id),
toggleChallenge: (id: string, enabled: boolean): Promise<Challenge> => toggleChallenge: (id: string, enabled: boolean): Promise<Challenge> =>
ipcRenderer.invoke(IPC.toggleChallenge, id, enabled), ipcRenderer.invoke(IPC.toggleChallenge, id, enabled),
markChallengeDone: (id: string, reps: number): Promise<boolean> =>
ipcRenderer.invoke(IPC.markChallengeDone, id, reps),
closeMatchSummary: (): Promise<void> => closeMatchSummary: (): Promise<void> =>
ipcRenderer.invoke(IPC.closeMatchSummary), ipcRenderer.invoke(IPC.closeMatchSummary),
@@ -118,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 =>
@@ -135,7 +171,10 @@ const api = {
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub => onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
on(IPC.evtUpdaterStatus, h), on(IPC.evtUpdaterStatus, h),
onMaximizeChanged: (h: Handler<boolean>): Unsub => onMaximizeChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMaximizeChanged, h) on(IPC.evtMaximizeChanged, h),
onMeetingChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMeetingChanged, h),
onHistoryChanged: (h: Handler<void>): Unsub => on(IPC.evtHistoryChanged, h)
} }
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)

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'
@@ -36,19 +38,31 @@ export default function App(): JSX.Element {
} }
}, []) }, [])
// После хидрации сверяем текущую версию приложения с lastSeenVersion. // Различаем три кейса по `lastSeenVersion`:
// Если первая хидрация и lastSeenVersion ещё не записан — это либо // 1) есть и !== current → классический update path, показываем
// первый запуск, либо обновление со старой версии (где поля не было) — // пропущенные заметки.
// в любом случае пишем текущую версию и НЕ показываем модалку (мы не // 2) есть и === current → ничего не делаем.
// хотим бить нового пользователя CHANGELOG'ом). // 3) нет (undefined) → это ИЛИ первый запуск нового пользователя,
// Если lastSeenVersion есть и не совпадает с current → показываем. // ИЛИ существующий пользователь, который апгрейдится с версии,
// где поля ещё не было (всё < 0.5.6).
// Разрешаем эту неоднозначность через proxy «уже пользовался
// приложением» — хотя бы одно упражнение имеет `lastDoneAt`.
// Новичкам тихо записываем; обновляющимся — показываем заметки
// текущей версии, чтобы они узнали про новые фичи.
useEffect(() => { useEffect(() => {
if (!hydrated || !settings) return if (!hydrated || !settings) return
const exercises = useAppStore.getState().state?.exercises ?? []
const isExistingUser = exercises.some((e) => e.lastDoneAt !== undefined)
void window.api.getAppVersion().then((current) => { void window.api.getAppVersion().then((current) => {
const last = settings.lastSeenVersion const last = settings.lastSeenVersion
if (!last) { if (!last) {
// Первая запись — сохраняем тихо. if (isExistingUser) {
window.api.updateSettings({ lastSeenVersion: current }) // Обновляющийся — показываем заметки текущей версии.
setWhatsNew({ open: true, versions: [current] })
} else {
// Новый — тихо записываем, не отвлекаем.
window.api.updateSettings({ lastSeenVersion: current })
}
return return
} }
if (last !== current) { if (last !== current) {
@@ -56,7 +70,6 @@ export default function App(): JSX.Element {
if (versions.length > 0) { if (versions.length > 0) {
setWhatsNew({ open: true, versions }) setWhatsNew({ open: true, versions })
} else { } else {
// Версии есть, заметок нет — просто обновляем.
window.api.updateSettings({ lastSeenVersion: current }) window.api.updateSettings({ lastSeenVersion: current })
} }
} }
@@ -89,8 +102,23 @@ export default function App(): JSX.Element {
<RoutedPages onNav={() => setMobileNavOpen(false)} /> <RoutedPages onNav={() => setMobileNavOpen(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>
@@ -125,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,54 +27,58 @@ 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-окне нет места для модалки,
* проще использовать встроенный диалог. */
function nativeConfirm(message: string): boolean {
// eslint-disable-next-line no-alert -- reminder window не имеет своей Modal-обвязки
return window.confirm(message)
}
export default function ReminderApp(): JSX.Element { 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 })
// «{exercise.name}, {n} раз/раза/раз». Простая локальная фраза без
// ключа в dict — короткая команда, не нуждается в полном переводе.
const lang = s.language ?? 'ru'
const phrase =
lang === 'ru'
? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}`
: `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}`
speak(phrase, lang)
}
}) })
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.
@@ -87,7 +92,91 @@ 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 остались незакрытые челленджи — подтверждаем,
// чтобы пользователь не «пролетел» окно по привычке и не потерял
// мотивацию (и метрики стрик/today_done).
if (mode.kind === 'match') {
const remaining = mode.summary.results.filter(
(r) => !mode.done.has(r.challengeId)
)
if (remaining.length > 0) {
const lang = settings?.language ?? 'ru'
const reps = remaining.reduce((s, r) => s + r.reps, 0)
const msg =
lang === 'ru'
? `Осталось ${remaining.length} ${
remaining.length === 1 ? 'челлендж' : 'челленджей'
} · ${reps} повторений. Закрыть окно?`
: `${remaining.length} ${
remaining.length === 1 ? 'challenge' : 'challenges'
} left · ${reps} reps. Close anyway?`
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()
} }
@@ -108,24 +197,48 @@ 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) => {
// Functional update so a second rapid click can't race against a stale // Дедупликация: rapid double-click может два раза вызвать
// `mode.done` captured in this closure. // onMarkDone до того как `disabled={done}` доедет до DOM.
// Раньше это писало в историю дважды → лишние +N reps.
if (sentChallengesRef.current.has(id)) return
sentChallengesRef.current.add(id)
// 1) IPC: записываем в историю (раньше делали только локальный set,
// из-за чего матч-челленджи не считались в стрик/achievements).
const result = mode.summary.results.find((r) => r.challengeId === id)
if (result && result.reps > 0) {
void window.api.markChallengeDone(id, result.reps)
}
// 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}
/> />
) )
@@ -303,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

@@ -1,4 +1,5 @@
import { useMemo } from 'react' import { useEffect, useMemo, useState } from 'react'
import { motion } from 'framer-motion'
import { Award, Activity, Flame, Sparkles, TrendingUp, Lock } from 'lucide-react' import { Award, Activity, Flame, Sparkles, TrendingUp, Lock } from 'lucide-react'
import type { Exercise, HistoryEntry } from '@shared/types' import type { Exercise, HistoryEntry } from '@shared/types'
import { import {
@@ -6,6 +7,28 @@ 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'
function loadCelebrated(): Set<string> {
try {
const raw = localStorage.getItem(CELEBRATED_KEY)
if (!raw) return new Set()
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? new Set(parsed) : new Set()
} catch {
return new Set()
}
}
function saveCelebrated(set: Set<string>): void {
try {
localStorage.setItem(CELEBRATED_KEY, JSON.stringify(Array.from(set)))
} catch {
/* localStorage может быть отключён — ничего страшного, просто будет celebrate каждый раз */
}
}
const ICON_BY_NAME = { const ICON_BY_NAME = {
Activity, Activity,
@@ -26,12 +49,47 @@ 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),
[history, exercises] [history, exercises]
) )
// Достижения, которые в этом mount пользователь впервые увидел unlocked
// (т.е. не были в localStorage до этого). Для них играем pulse-анимацию.
// После анимации помечаем как celebrated, чтобы при следующем рендере
// они уже не подпрыгивали.
const [freshlyUnlocked, setFreshlyUnlocked] = useState<Set<string>>(
() => new Set()
)
useEffect(() => {
const celebrated = loadCelebrated()
const fresh = new Set<string>()
for (const a of achievements) {
if (a.unlocked && !celebrated.has(a.def.id)) {
fresh.add(a.def.id)
celebrated.add(a.def.id)
}
}
if (fresh.size > 0) {
setFreshlyUnlocked(fresh)
saveCelebrated(celebrated)
// Озвучиваем разблокировку для screen-reader'ов — pulse-анимацию они
// не видят.
for (const a of achievements) {
if (fresh.has(a.def.id)) {
announce(t('achievements.announce', { title: t(a.def.titleKey) }))
}
}
// Снимаем «свежесть» через 5 сек чтобы pulse не крутился вечно.
const timer = setTimeout(() => setFreshlyUnlocked(new Set()), 5_000)
return () => clearTimeout(timer)
}
return undefined
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [achievements])
const unlocked = achievements.filter((a) => a.unlocked) const unlocked = achievements.filter((a) => a.unlocked)
const locked = achievements.filter((a) => !a.unlocked) const locked = achievements.filter((a) => !a.unlocked)
// Сортируем locked по близости к unlock'у — чтобы «осталось 12» // Сортируем locked по близости к unlock'у — чтобы «осталось 12»
@@ -67,14 +125,24 @@ export function AchievementsCard({ history, exercises }: Props): JSX.Element {
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{visible.map((a) => ( {visible.map((a) => (
<Badge key={a.def.id} a={a} /> <Badge
key={a.def.id}
a={a}
fresh={freshlyUnlocked.has(a.def.id)}
/>
))} ))}
</div> </div>
</div> </div>
) )
} }
function Badge({ a }: { a: AchievementProgress }): JSX.Element { function Badge({
a,
fresh
}: {
a: AchievementProgress
fresh: boolean
}): JSX.Element {
const { t } = useT() const { t } = useT()
const IconCmp = ICON_BY_NAME[a.def.icon as keyof typeof ICON_BY_NAME] ?? Award const IconCmp = ICON_BY_NAME[a.def.icon as keyof typeof ICON_BY_NAME] ?? Award
const pct = Math.min(100, Math.round((a.current / a.target) * 100)) const pct = Math.min(100, Math.round((a.current / a.target) * 100))
@@ -86,7 +154,28 @@ function Badge({ a }: { a: AchievementProgress }): JSX.Element {
}[a.def.tone] }[a.def.tone]
return ( return (
<div <motion.div
animate={
fresh
? {
scale: [1, 1.08, 1],
boxShadow: [
'0 0 0 0 rgb(var(--accent) / 0)',
'0 0 0 6px rgb(var(--accent) / 0.4)',
'0 0 0 0 rgb(var(--accent) / 0)'
]
}
: {}
}
transition={
fresh
? {
duration: 1.4,
repeat: 2,
ease: 'easeInOut'
}
: {}
}
className={[ className={[
'rounded-xl p-2.5 transition-opacity', 'rounded-xl p-2.5 transition-opacity',
a.unlocked ? 'bg-surface-2' : 'bg-surface-2 opacity-55' a.unlocked ? 'bg-surface-2' : 'bg-surface-2 opacity-55'
@@ -123,6 +212,6 @@ function Badge({ a }: { a: AchievementProgress }): JSX.Element {
</div> </div>
</> </>
)} )}
</div> </motion.div>
) )
} }

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,15 +1,18 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Check, MoreHorizontal } 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
tick?: Tick tick?: Tick
/** Сделано повторений сегодня (для daily-goal индикатора). */
doneToday?: number
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onToggle: (enabled: boolean) => void onToggle: (enabled: boolean) => void
@@ -24,6 +27,7 @@ type Props = {
export function ExerciseCard({ export function ExerciseCard({
exercise, exercise,
tick, tick,
doneToday,
onEdit, onEdit,
onDelete, onDelete,
onToggle, onToggle,
@@ -33,9 +37,71 @@ export function ExerciseCard({
const total = exercise.intervalMinutes * 60_000 const total = exercise.intervalMinutes * 60_000
const remaining = Math.max(0, Math.min(total, ms)) const remaining = Math.max(0, Math.min(total, ms))
const elapsedPct = total > 0 ? 1 - remaining / total : 0 const elapsedPct = total > 0 ? 1 - remaining / total : 0
const isDue = ms <= 0 && exercise.enabled const goalReached =
exercise.dailyGoal !== undefined &&
exercise.dailyGoal > 0 &&
(doneToday ?? 0) >= exercise.dailyGoal
// Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем.
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
@@ -102,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>
@@ -114,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()
@@ -125,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()
@@ -148,16 +225,48 @@ export function ExerciseCard({
{/* Countdown + switch */} {/* Countdown + switch */}
<div className="flex items-end justify-between mt-3.5"> <div className="flex items-end justify-between mt-3.5">
<div> <div>
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold"> <div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold inline-flex items-center gap-1">
{isDue ? t('dashboard.stat.next.now') : t('fmt.through')} {goalReached ? (
<>
<CheckCircle2
size={11}
strokeWidth={2.5}
className="text-success"
/>
{t('exercise.goal_reached.kicker')}
</>
) : isDue ? (
t('dashboard.stat.next.now')
) : (
t('fmt.through')
)}
{exercise.adaptive && !goalReached && (
<Brain
size={11}
strokeWidth={2.5}
className="text-info ml-0.5"
aria-label={t('exercise.adaptive.badge')}
/>
)}
</div> </div>
<div <div
className={[ className={[
'font-mono-num text-[24px] font-bold leading-none mt-1 tracking-tight', 'font-mono-num text-[24px] font-bold leading-none mt-1 tracking-tight',
isDue ? 'text-accent' : 'text-text' goalReached
? 'text-success'
: isDue
? 'text-accent'
: 'text-text'
].join(' ')} ].join(' ')}
> >
{exercise.enabled ? formatCountdown(ms, lang) : t('fmt.paused')} {!exercise.enabled
? t('fmt.paused')
: goalReached
? t('exercise.goal_reached.value', {
done: doneToday ?? 0,
goal: exercise.dailyGoal ?? 0
})
: formatCountdown(ms, lang)}
</div> </div>
</div> </div>
<Switch <Switch
@@ -173,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

@@ -1,7 +1,15 @@
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'
type Item = { type Item = {
@@ -20,6 +28,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',

View File

@@ -0,0 +1,62 @@
import { Modal } from './Modal'
import { Button } from './Button'
import { useT } from '../../i18n'
type Props = {
open: boolean
title: string
/** Текст вопроса (одно-два предложения). */
message: string
/** Текст кнопки подтверждения, default «OK». */
confirmLabel?: string
/** Текст кнопки отмены, default «Отмена». */
cancelLabel?: string
/** Подсветить confirm красным (delete-style). */
destructive?: boolean
onConfirm: () => void
onCancel: () => void
}
/**
* iOS-style confirm-диалог. Заменяет `window.confirm()` — нативный prompt
* выглядит инородно в дизайне приложения, без вибрации accent-цвета и без
* focus-trap'a. Использует наш Modal под капотом, поэтому получаем
* focus-trap и Esc-to-cancel бесплатно.
*/
export function ConfirmModal({
open,
title,
message,
confirmLabel,
cancelLabel,
destructive,
onConfirm,
onCancel
}: Props): JSX.Element {
const { t } = useT()
return (
<Modal
open={open}
onClose={onCancel}
title={title}
size="sm"
footer={
<>
<Button variant="plain" onClick={onCancel}>
{cancelLabel ?? t('btn.cancel')}
</Button>
<Button
variant={destructive ? 'destructive' : 'filled'}
onClick={onConfirm}
>
{confirmLabel ?? t('btn.ok')}
</Button>
</>
}
>
<div className="text-[14px] text-text/85 leading-relaxed py-1">
{message}
</div>
</Modal>
)
}

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ export const ru: Dict = {
// Sidebar / nav // Sidebar / nav
'nav.today': 'Сегодня', 'nav.today': 'Сегодня',
'nav.exercises': 'Упражнения', 'nav.exercises': 'Упражнения',
'nav.meals': 'Питание',
'nav.games': 'Игры', 'nav.games': 'Игры',
'nav.challenges': 'Челленджи', 'nav.challenges': 'Челленджи',
'nav.settings': 'Настройки', 'nav.settings': 'Настройки',
@@ -31,6 +32,15 @@ export const ru: Dict = {
'btn.add': 'Добавить', 'btn.add': 'Добавить',
'btn.new': 'Новый', 'btn.new': 'Новый',
'btn.cancel': 'Отмена', 'btn.cancel': 'Отмена',
'btn.ok': 'OK',
'btn.delete.confirm': 'Удалить',
'exercise.delete.title': 'Удалить упражнение?',
'exercise.delete.body':
'Упражнение «{name}» будет удалено. История останется, но в ней останется только название.',
'exercise.goal_reached.kicker': 'Цель закрыта',
'exercise.goal_reached.value': '{done}/{goal}',
'exercise.adaptive.badge': 'адаптивный режим',
'settings.data.import.modal.title': 'Восстановить из файла?',
'btn.save': 'Сохранить', 'btn.save': 'Сохранить',
'btn.done': 'Готово', 'btn.done': 'Готово',
'btn.start': 'Старт', 'btn.start': 'Старт',
@@ -65,10 +75,52 @@ 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.subtitle_on': 'в реальном времени', 'dashboard.stat.tracking.subtitle_on': 'в реальном времени',
'dashboard.stat.tracking.subtitle_off': 'выключен', 'dashboard.stat.tracking.subtitle_off': 'выключен',
'dashboard.stat.tracking.subtitle_pending':
'нужно закрыть Steam и снова открыть',
'dashboard.paused.title': 'Напоминания на паузе', 'dashboard.paused.title': 'Напоминания на паузе',
'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт', 'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт',
'dashboard.meeting.title': 'Не дёргаем — ты на встрече',
'dashboard.meeting.hint':
'Запущен Zoom / Teams / Discord / 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.reps': '{n} раз',
'dashboard.empty.title': 'Программа пуста', 'dashboard.empty.title': 'Программа пуста',
'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать', 'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать',
@@ -80,6 +132,34 @@ export const ru: Dict = {
'exercises.row.meta': '{reps} раз · {interval}', 'exercises.row.meta': '{reps} раз · {interval}',
'exercises.empty': 'Программа пуста — добавь первое упражнение', 'exercises.empty': 'Программа пуста — добавь первое упражнение',
// Meals (приёмы пищи)
'meals.kicker': 'Режим питания',
'meals.title': 'Питание',
'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': 'Редактировать',
@@ -144,6 +224,7 @@ export const ru: Dict = {
'settings.section.language': 'Язык', 'settings.section.language': 'Язык',
'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-файл.',
@@ -158,7 +239,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': 'Открыть',
@@ -222,9 +319,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': 'Авто-проверка раз в час',
@@ -233,6 +332,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': 'Пятьсот',
@@ -329,6 +429,7 @@ export const en: Dict = {
// Sidebar / nav // Sidebar / nav
'nav.today': 'Today', 'nav.today': 'Today',
'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',
@@ -346,6 +447,15 @@ export const en: Dict = {
'btn.add': 'Add', 'btn.add': 'Add',
'btn.new': 'New', 'btn.new': 'New',
'btn.cancel': 'Cancel', 'btn.cancel': 'Cancel',
'btn.ok': 'OK',
'btn.delete.confirm': 'Delete',
'exercise.delete.title': 'Delete exercise?',
'exercise.delete.body':
'Exercise "{name}" will be removed. History stays but will only show the name.',
'exercise.goal_reached.kicker': 'Goal hit',
'exercise.goal_reached.value': '{done}/{goal}',
'exercise.adaptive.badge': 'adaptive mode',
'settings.data.import.modal.title': 'Restore from file?',
'btn.save': 'Save', 'btn.save': 'Save',
'btn.done': 'Done', 'btn.done': 'Done',
'btn.start': 'Start', 'btn.start': 'Start',
@@ -380,10 +490,51 @@ export const en: Dict = {
'dashboard.stat.tracking': 'Match tracking', 'dashboard.stat.tracking': 'Match 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.subtitle_on': 'real-time', 'dashboard.stat.tracking.subtitle_on': 'real-time',
'dashboard.stat.tracking.subtitle_off': 'disabled', 'dashboard.stat.tracking.subtitle_off': 'disabled',
'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.hint':
'Zoom / Teams / Discord / Webex / Slack-huddle is running. Reminders resume when you close it.',
'dashboard.paused.hint': 'Resume to continue countdown', 'dashboard.paused.hint': 'Resume to continue countdown',
'dashboard.plan.title': 'Day plan',
'dashboard.plan.subtitle': 'Next action and daily goals',
'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.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',
@@ -395,6 +546,34 @@ export const en: Dict = {
'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.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',
@@ -459,6 +638,7 @@ export const en: Dict = {
'settings.section.language': 'Language', 'settings.section.language': 'Language',
'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.',
@@ -473,7 +653,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',
@@ -537,9 +733,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',
@@ -548,6 +746,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

@@ -42,7 +42,7 @@ function totalDoneReps(
let sum = 0 let sum = 0
for (const e of history) { for (const e of history) {
if (e.action !== 'done') continue if (e.action !== 'done') continue
sum += e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0 sum += e.actualReps ?? e.reps ?? byId.get(e.exerciseId)?.reps ?? 0
} }
return sum return sum
} }

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

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

@@ -30,6 +30,35 @@ function shiftDays(base: Date, dayDelta: number): Date {
* Reps logged on a given local day. Uses `actualReps` if present, otherwise * Reps logged on a given local day. Uses `actualReps` if present, otherwise
* looks up exercise's planned `reps`. * looks up exercise's planned `reps`.
*/ */
/**
* Сколько reps пользователь сделал в заданный day-key. Источники в порядке
* приоритета:
* 1. entry.actualReps — что фактически сделал (stepper в reminder'е)
* 2. entry.reps — snapshot planned-reps на момент записи (выживает после
* удаления упражнения и работает для match-челленджей у которых нет
* связанного Exercise)
* 3. byId.get(exerciseId).reps — fallback для старых entries без snapshot'а
*/
/**
* Сколько reps конкретное упражнение принесло за сегодня. Учитываем как
* обычные «по таймеру», так и match-челленджи (если их exerciseId совпадает,
* чего обычно нет; но fallback не помешает).
*/
export function repsDoneTodayForExercise(
entries: HistoryEntry[],
exercise: Exercise
): number {
const today = todayKey()
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
}
export function dailyReps( export function dailyReps(
entries: HistoryEntry[], entries: HistoryEntry[],
exercises: Exercise[], exercises: Exercise[],
@@ -40,7 +69,7 @@ export function dailyReps(
for (const e of entries) { for (const e of entries) {
if (e.action !== 'done') continue if (e.action !== 'done') continue
if (dayKey(e.ts) !== dayKeyStr) continue if (dayKey(e.ts) !== dayKeyStr) continue
sum += e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0 sum += e.actualReps ?? e.reps ?? byId.get(e.exerciseId)?.reps ?? 0
} }
return sum return sum
} }
@@ -72,7 +101,7 @@ export function dailyRepsRange(
const k = dayKey(e.ts) const k = dayKey(e.ts)
const bucket = buckets.get(k) const bucket = buckets.get(k)
if (!bucket) continue if (!bucket) continue
const reps = e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0 const reps = e.actualReps ?? e.reps ?? byId.get(e.exerciseId)?.reps ?? 0
bucket.reps += reps bucket.reps += reps
} }

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,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,26 @@
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'
installRendererErrorReporting()
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'
// reducedMotion="user" — framer-motion сам читает системную настройку
// «уменьшить движение» и глушит transform/layout-анимации (оставляя opacity).
// Один источник истины для обоих окон и всех motion-компонентов.
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <MotionConfig reducedMotion="user">
{which === 'reminder' ? <ReminderApp /> : <App />} <ThemeProvider>
</ThemeProvider> {which === 'reminder' ? <ReminderApp /> : <App />}
</ThemeProvider>
</MotionConfig>
</React.StrictMode> </React.StrictMode>
) )

View File

@@ -22,12 +22,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
} }
@@ -135,8 +139,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

@@ -1,39 +1,107 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { Plus, Pause, Play, Flame, Activity, TrendingUp } from 'lucide-react' import {
Plus,
Pause,
Play,
Flame,
Activity,
TrendingUp,
Video,
CalendarCheck,
Target,
RotateCcw,
Check
} from 'lucide-react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard' import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor' import { ExerciseEditor } from '../components/ExerciseEditor'
import { HistoryHeatmap } from '../components/HistoryHeatmap' 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 type { Exercise, HistoryEntry } from '@shared/types' import { ConfirmModal } from '../components/ui/ConfirmModal'
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 { currentStreak, dailyReps, todayKey } from '../lib/history' import { Icon } from '../lib/icon'
import {
computeTodayPlan,
type PlanItem,
type TodayPlan
} from '../lib/day-plan'
import {
currentStreak,
dailyReps,
repsDoneTodayForExercise,
todayKey
} from '../lib/history'
export default function Dashboard(): JSX.Element { export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state) const state = useAppStore((s) => s.state)
const ticks = useAppStore((s) => s.ticks) const ticks = useAppStore((s) => s.ticks)
const [editorOpen, setEditorOpen] = useState(false) const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Exercise | null>(null) const [editing, setEditing] = useState<Exercise | null>(null)
const [deleting, setDeleting] = useState<Exercise | null>(null)
const { t, lang } = useT() const { t, lang } = useT()
// Memoise the exercises array reference so downstream useMemos don't fire // Memoise the exercises array reference so downstream useMemos don't fire
// 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 settings = state?.settings const settings = state?.settings
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean) const [planActionKey, setPlanActionKey] = useState<string | null>(null)
// Local history mirror; reloaded only when exercises change (not on every // Игры: запрашиваем реальный статус (integrationActive + launchOption
// tick or settings tweak — those don't affect history). When ticks/settings // applied), а не просто `state.gamesEnabled`. Без этого badge показывал
// change we don't re-fetch. // success-зелёный даже когда launch option ещё «queued» (Steam перезапи-
// сывает) — пользователь думал что GSI работает, а fires не приходили.
const [games, setGames] = useState<GameStatus[]>([])
useEffect(() => {
void window.api.listGames().then(setGames)
return window.api.onGamesChanged(setGames)
}, [])
const gamesLive = games.some(
(g) =>
g.enabled && g.integrationActive && g.launchOptionStatus === 'applied'
)
// «Включена, но не готова» — отдельное состояние, в badge другой tone.
const gamesEnabledButNotLive = games.some(
(g) =>
g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied')
)
// Local history mirror. Перетягиваем (а) на mount, (б) при изменении
// exercises (add/delete/edit — могут поменять name/icon в snapshot'ах
// для будущих entries), (в) при evtHistoryChanged — это event который
// main отправляет ПОСЛЕ любого markDone/markChallengeDone/clearHistory/
// import. Без (в) heatmap и стрик стояли на месте после markDone —
// store мутирует exercise in place, ref не меняется, useEffect не
// fire'ил.
const [history, setHistory] = useState<HistoryEntry[]>([]) 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 +
// запрашиваем актуальное значение при mount. Показываем баннер только
// если фича включена в settings.
const [meetingActive, setMeetingActive] = useState(false)
useEffect(() => {
void window.api.getMeetingActive().then(setMeetingActive)
return window.api.onMeetingChanged(setMeetingActive)
}, [])
const meetingPaused = meetingActive && settings?.meetingAutoPause === true
const todayDone = useMemo( const todayDone = useMemo(
() => dailyReps(history, exercises, todayKey()), () => dailyReps(history, exercises, todayKey()),
[history, exercises] [history, exercises]
@@ -57,6 +125,11 @@ 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 paused = !settings?.globalEnabled const paused = !settings?.globalEnabled
function openCreate(): void { function openCreate(): void {
@@ -85,6 +158,17 @@ 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',
@@ -158,36 +242,39 @@ export default function Dashboard(): JSX.Element {
icon={<Activity size={14} strokeWidth={2.6} />} icon={<Activity size={14} strokeWidth={2.6} />}
/> />
<HeroStat <HeroStat
tone={gamesEnabled ? 'success' : 'muted'} tone={
gamesLive
? 'success'
: gamesEnabledButNotLive
? 'warning'
: 'muted'
}
label={t('dashboard.stat.tracking')} label={t('dashboard.stat.tracking')}
value={ value={
gamesEnabled gamesLive
? t('dashboard.stat.tracking.on') ? t('dashboard.stat.tracking.on')
: t('dashboard.stat.tracking.off') : gamesEnabledButNotLive
? t('dashboard.stat.tracking.pending')
: t('dashboard.stat.tracking.off')
} }
subvalue={ subvalue={
gamesEnabled gamesLive
? t('dashboard.stat.tracking.subtitle_on') ? t('dashboard.stat.tracking.subtitle_on')
: t('dashboard.stat.tracking.subtitle_off') : gamesEnabledButNotLive
? t('dashboard.stat.tracking.subtitle_pending')
: t('dashboard.stat.tracking.subtitle_off')
} }
icon={ icon={
<span <span
className={[ className={[
'w-1.5 h-1.5 rounded-full', 'w-1.5 h-1.5 rounded-full',
gamesEnabled ? 'bg-white' : 'bg-text/30' gamesLive ? 'bg-white' : 'bg-text/30'
].join(' ')} ].join(' ')}
/> />
} }
/> />
</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 }}
@@ -211,6 +298,42 @@ export default function Dashboard(): JSX.Element {
</motion.div> </motion.div>
)} )}
{!paused && meetingPaused && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 rounded-2xl bg-info/12 p-4 flex items-center gap-3"
>
<div className="w-10 h-10 rounded-xl bg-info/18 text-info grid place-items-center shrink-0">
<Video size={18} strokeWidth={2.5} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[16px] font-semibold leading-tight">
{t('dashboard.meeting.title')}
</div>
<div className="text-[14px] text-text/70 mt-1">
{t('dashboard.meeting.hint')}
</div>
</div>
</motion.div>
)}
<TodayPlanPanel
plan={plan}
paused={paused}
lang={lang}
t={t}
actionBusy={planActionKey !== null}
onItemDone={(item) => void handlePlanItemDone(item)}
/>
{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) => (
@@ -218,8 +341,13 @@ export default function Dashboard(): JSX.Element {
key={ex.id} key={ex.id}
exercise={ex} exercise={ex}
tick={ticks[ex.id]} tick={ticks[ex.id]}
doneToday={
ex.dailyGoal !== undefined
? repsDoneTodayForExercise(history, ex)
: undefined
}
onEdit={() => openEdit(ex)} onEdit={() => openEdit(ex)}
onDelete={() => window.api.deleteExercise(ex.id)} onDelete={() => setDeleting(ex)}
onToggle={(v) => window.api.toggleExercise(ex.id, v)} onToggle={(v) => window.api.toggleExercise(ex.id, v)}
onMarkDone={() => window.api.markDone(ex.id)} onMarkDone={() => window.api.markDone(ex.id)}
/> />
@@ -247,11 +375,358 @@ export default function Dashboard(): JSX.Element {
onClose={() => setEditorOpen(false)} onClose={() => setEditorOpen(false)}
onSave={handleSave} onSave={handleSave}
/> />
<ConfirmModal
open={deleting !== null}
title={t('exercise.delete.title')}
message={t('exercise.delete.body', { name: deleting?.name ?? '' })}
confirmLabel={t('btn.delete.confirm')}
destructive
onConfirm={() => {
if (deleting) void window.api.deleteExercise(deleting.id)
setDeleting(null)
}}
onCancel={() => setDeleting(null)}
/>
</div> </div>
</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
}: {
pct: number
tone: 'accent' | 'success'
}): JSX.Element {
return (
<div className="mt-3 h-2 rounded-full bg-hairline/35 overflow-hidden">
<div
className={[
'h-full rounded-full transition-all duration-300',
tone === 'accent' ? 'bg-accent' : 'bg-success'
].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', {
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

@@ -93,8 +93,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

@@ -11,6 +11,7 @@ import {
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
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'
@@ -104,7 +105,11 @@ 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 +206,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 +221,8 @@ 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,195 @@
import { useState } from 'react'
import { Plus, ChevronRight, UtensilsCrossed } from 'lucide-react'
import { AnimatePresence, motion } from 'framer-motion'
import { useAppStore } from '../store/appStore'
import { MealEditor, type MealDraft } from '../components/MealEditor'
import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { Icon } from '../lib/icon'
import { useT } from '../i18n'
import { MEAL_PRESETS, type Meal } from '@shared/types'
/** Сводка дней недели приёма пищи: «ежедневно» или короткие названия. */
function daysLabel(days: number[], t: (k: string) => string): string {
if (days.length === 0) return t('meals.everyday')
const labels = t('meals.weekdays').split(',')
// Порядок Пн..Вс для читабельности.
const order = [1, 2, 3, 4, 5, 6, 0]
return order
.filter((d) => days.includes(d))
.map((d) => labels[d])
.join(', ')
}
export default function Meals(): JSX.Element {
const meals = useAppStore((s) => s.state?.meals ?? [])
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Meal | null>(null)
const { t } = useT()
// Единый список (включённые сверху). Важно: НЕ разбиваем на два <Card>, иначе
// при переключении строка переезжает между списками → её Switch
// размонтируется/монтируется заново, и анимация ползунка есть только при
// включении (mount с x:0→20), а при выключении нет. В одном keyed-списке
// компонент остаётся смонтированным → ползунок плавно ездит в обе стороны,
// а строка «переезжает» в свою группу через layout-анимацию.
const ordered = [...meals].sort((a, b) =>
a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1
)
async function addPreset(
preset: (typeof MEAL_PRESETS)[number]
): Promise<void> {
await window.api.addMeal({
name: t(preset.nameKey),
time: preset.time,
icon: preset.icon,
enabled: true,
days: []
})
}
return (
<div className="h-full overflow-y-auto">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div>
<div className="text-[14px] text-text/65 font-semibold">
{t('meals.kicker')}
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
{t('meals.title')}
</h1>
</div>
<Button
onClick={() => {
setEditing(null)
setEditorOpen(true)
}}
>
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
</Button>
</div>
{/* Пресеты быстрого добавления */}
<SectionHeader title={t('meals.presets')} />
<div className="flex flex-wrap gap-2 mb-7">
{MEAL_PRESETS.map((p) => (
<button
key={p.nameKey}
onClick={() => addPreset(p)}
className="inline-flex items-center gap-2 h-10 px-3.5 rounded-2xl bg-surface-2 hover:bg-accent/15 hover:text-accent text-text/80 text-[14px] font-semibold transition-colors active:scale-95"
>
<Icon name={p.icon} size={16} strokeWidth={2.3} />
{t(p.nameKey)}
<span className="font-mono-num text-text/45 text-[13px]">
{p.time}
</span>
</button>
))}
</div>
{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 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,24 +1,42 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Copy, FolderOpen, 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 { 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 { 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 } 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-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12 space-y-5"
role="status"
aria-label={t('settings.loading')}
>
<Skeleton className="h-10 w-56" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-24" />
</div>
)
const patch = (p: Partial<SettingsType>): void => { const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p) window.api.updateSettings(p)
@@ -178,6 +196,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 />
@@ -187,9 +210,160 @@ export default function SettingsPage(): JSX.Element {
) )
} }
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)
@@ -199,6 +373,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">
@@ -226,8 +413,11 @@ 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)
// Простое toast'-сообщение в карточке; через 4 сек чистится. // Простое toast'-сообщение в карточке; через 4 сек чистится.
useEffect(() => { useEffect(() => {
@@ -237,31 +427,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 onImport(): Promise<void> { async function performImport(): Promise<void> {
// eslint-disable-next-line no-alert -- modal-confirm для destructive action setConfirmOpen(false)
if (!window.confirm(t('settings.data.import.confirm'))) return setBusy('import')
setBusy(true)
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)
} }
} }
@@ -278,9 +469,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>
@@ -294,10 +486,11 @@ function DataCard(): JSX.Element {
</div> </div>
</div> </div>
<button <button
onClick={onImport} 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>
@@ -306,6 +499,15 @@ function DataCard(): JSX.Element {
{toast} {toast}
</div> </div>
)} )}
<ConfirmModal
open={confirmOpen}
title={t('settings.data.import.modal.title')}
message={t('settings.data.import.confirm')}
confirmLabel={t('settings.data.import.btn')}
destructive
onConfirm={performImport}
onCancel={() => setConfirmOpen(false)}
/>
</Card> </Card>
) )
} }
@@ -353,11 +555,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.
@@ -371,7 +572,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,11 +71,24 @@ 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',
evtAccentChanged: 'evt:accentChanged', evtAccentChanged: 'evt:accentChanged',
evtGamesChanged: 'evt:gamesChanged', evtGamesChanged: 'evt:gamesChanged',
evtUpdaterStatus: 'evt:updaterStatus', evtUpdaterStatus: 'evt:updaterStatus',
evtMaximizeChanged: 'evt:maximizeChanged' evtMaximizeChanged: 'evt:maximizeChanged',
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'
} 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,136 @@ 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.5.8': {
ru: [
{
title: 'Heatmap и стрик обновляются сразу после нажатия «Готово»',
detail:
'Был регресс — статистика не обновлялась пока не изменишь упражнение. Починено через новое событие evtHistoryChanged.',
tag: 'fix'
},
{
title: 'Двойной клик на ✓ больше не пишет 2 раза',
detail:
'Rapid double-click на ✓ в Match Summary и «Готово» давал лишние повторы в стрик. Добавлен ref-based дедуп.',
tag: 'fix'
},
{
title: 'Native save/open dialogs локализованы',
tag: 'fix'
},
{
title: '+18 тестов на новые модули',
detail:
'achievements, match-challenge edge cases, deleted exercise survival.',
tag: 'new'
}
],
en: [
{
title: 'Heatmap and streak update immediately after pressing "Done"',
detail:
'There was a regression — stats did not update until you edited an exercise. Fixed via new evtHistoryChanged event.',
tag: 'fix'
},
{
title: 'Double-click on ✓ no longer writes twice',
detail:
'Rapid double-click on ✓ in Match Summary and "Done" added extra reps to streak. ref-based dedup.',
tag: 'fix'
},
{
title: 'Native save/open dialogs localised',
tag: 'fix'
},
{
title: '+18 tests for new modules',
detail:
'achievements, match-challenge edge cases, deleted exercise survival.',
tag: 'new'
}
]
},
'0.5.7': {
ru: [
{
title: 'Челленджи из матчей идут в историю',
detail:
'Раньше ✓ в Match Summary не считался — стрик и достижения игнорировали игровые тренировки. Исправлено.',
tag: 'fix'
},
{
title: 'Пауза из трея и из Dashboard теперь синхронизированы',
detail: 'Раньше Dashboard показывал «running» когда tray был на паузе.',
tag: 'fix'
},
{
title: 'Удаление упражнения спрашивает подтверждение',
tag: 'fix'
},
{
title: 'Daily goal: «Цель закрыта · 100/100»',
detail:
'Когда дневная цель достигнута — больше не показываем обратный отсчёт «25ч 13м».',
tag: 'fix'
},
{
title: 'Видно когда мы молчим из-за ВКС',
detail:
'Запущен Zoom/Teams — на Dashboard баннер «Не дёргаем — ты на встрече».',
tag: 'new'
},
{
title: 'Celebration анимация на новых достижениях',
tag: 'new'
},
{
title: 'Tracking-badge точнее',
detail:
'Live / Setup (закрой Steam) / Off — раньше зелёный показывался даже когда launch option не применён.',
tag: 'fix'
}
],
en: [
{
title: 'Match challenges now write to history',
detail:
'Previously ✓ in Match Summary did not count — streak and achievements ignored game training. Fixed.',
tag: 'fix'
},
{
title: 'Tray pause and Dashboard pause are now synced',
detail: "Previously Dashboard showed 'running' while tray was paused.",
tag: 'fix'
},
{
title: 'Exercise deletion asks for confirmation',
tag: 'fix'
},
{
title: 'Daily goal: "Goal hit · 100/100"',
detail:
'When the daily goal is met — no more confusing 25h countdown to tomorrow.',
tag: 'fix'
},
{
title: "Visible when we're quiet because of a meeting",
detail:
"Zoom/Teams running — Dashboard shows a banner: 'You're in a meeting'.",
tag: 'new'
},
{
title: 'Celebration animation on newly unlocked achievements',
tag: 'new'
},
{
title: 'Tracking badge more accurate',
detail:
'Live / Setup (close Steam) / Off — previously green even when launch option was not applied.',
tag: 'fix'
}
]
},
'0.5.6': { '0.5.6': {
ru: [ ru: [
{ {

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'
@@ -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>>
@@ -111,13 +146,35 @@ export type PersistedState = AppState & {
export type HistoryAction = 'done' | 'skip' | 'snooze' export type HistoryAction = 'done' | 'skip' | 'snooze'
/**
* Источник записи: обычное напоминание (от scheduler'а), приём пищи или
* матч (челлендж).
* Используется для UI («подтянулся в матче» vs «по таймеру») и аналитики.
*/
export type HistorySource = 'reminder' | 'meal' | 'match'
export type HistoryEntry = { export type HistoryEntry = {
/** ms epoch */ /** ms epoch */
ts: number ts: number
/**
* Для обычных напоминаний — Exercise.id. Для challenge'ей — `challenge:<id>`
* (синтетический ключ; renderer'у не нужно искать exercise по нему).
*/
exerciseId: string exerciseId: string
action: HistoryAction action: HistoryAction
/** When user did less than planned. Only meaningful for `done`. */ /** When user did less than planned. Only meaningful for `done`. */
actualReps?: number actualReps?: number
/**
* Snapshot повторений на момент записи. Гарантирует, что после удаления
* упражнения история не теряет «сколько было сделано» (раньше lookup
* `byId.get(exerciseId).reps` возвращал undefined → heatmap показывал 0).
* Для match-челленджей — фактическое число повторов из match summary.
*/
reps?: number
/** Snapshot названия упражнения/челленджа — для будущего log-view. */
name?: string
/** undefined = reminder (для обратной совместимости со старыми entries). */
source?: HistorySource
} }
export type Tick = { export type Tick = {
@@ -243,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])
@@ -293,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: 'Приседания',
@@ -336,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 }
@@ -351,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
}