13 Commits
v0.5.6 ... HEAD

Author SHA1 Message Date
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
46 changed files with 2416 additions and 215 deletions

View File

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

View File

@@ -4,7 +4,7 @@
## TL;DR
**Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.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)
- **UI**: React 18 + TypeScript 5 + Tailwind 3 + framer-motion + react-router (HashRouter) + zustand 5
- **Auto-update**: electron-updater 6, generic provider, фиксированный канал
- **Тесты**: Vitest 4 (53 теста, все зелёные)
- **Тесты**: Vitest 4 (203 теста, все зелёные)
- **Lint/format**: ESLint 8 (flat-ish .eslintrc.cjs) + Prettier 3 + EditorConfig
- **Иконки**: lucide-react (whitelisted lookup через `ICON_CHOICES`)
- **Шрифты**: Plus Jakarta Sans, Bricolage Grotesque, JetBrains Mono (Google Fonts CDN)
@@ -38,8 +38,14 @@
- string cap 200 chars, enum-валидация для theme/lang/notify-mode/stat
- HH:MM regex для quietHours, dedup days
- Strip `id` из updateExercise/updateChallenge patch
- **Error-boundary**: все хендлеры обёрнуты в `safeHandle`/`safeOn` (`src/main/ipc.ts`) — исключение логируется в `latest.log`, наружу уходит generic `ipc-failed` (не падаем молча, не утекают детали)
- **Dev-only**: `dev:simulateMatchEnd` gated на `!app.isPackaged`
### Отказоустойчивость main
- **Глобальные хендлеры** в `src/main/index.ts`: `uncaughtException` (лог + `flushNow`) и `unhandledRejection` (лог) — процесс не исчезает молча
- **tasklist timeout** 4s в `meeting-detect.ts` (зависший child не копится)
- **Sync write на exit** через `Atomics.wait` (не busy-spin) в `store.ts`
### Auto-update (КРИТИЧНО)
- **Фиксированный URL канала**: `…/releases/download/update-channel/latest.yml` — никогда не меняется
- **НЕ** `…/releases/download/v${version}/…` (старая схема ломалась: установленная копия видела только свой релиз)
@@ -100,7 +106,7 @@ npm run release -- -Bump patch
## Gitea remote
- URL: `https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude` (Punycode для `президент.рф`)
- URL: `https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude` (Punycode для `президент.рф`; Gitea переехал с `…/git/` на сабдомен `git.`)
- User: `anril`
- Auth: см. `~/.claude/projects/.../memory/gitea_remote.md`
- **Actions выключены** (`has_actions: false`) — релизим через PowerShell, runners не настроены
@@ -122,18 +128,32 @@ npm run release -- -Bump patch
| `src/renderer/src/pages/Dashboard.tsx` | главная |
| `src/renderer/src/ReminderApp.tsx` | окно напоминания |
## Тесты (53)
## Тесты (203)
```
src/shared/types.test.ts (4)
src/shared/quiet-hours.test.ts (7)
src/renderer/src/lib/format.test.ts (8)
src/renderer/src/lib/history.test.ts (13)
src/main/games/vdf.test.ts (11)
src/renderer/src/i18n/i18n.test.ts (10)
src/main/validate.test.ts (68)
src/renderer/src/lib/history.test.ts (31)
src/renderer/src/i18n/i18n.test.ts (15)
src/renderer/src/lib/format.test.ts (14)
src/main/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/main/scheduler.test.ts (8) ← main: gating-логика
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 (3)
```
Покрываются: helpers, история/стрики (DST), тихие часы (wrap+filter), VDF-парсер Steam, i18n с плюрализацией, дефолты.
Покрываются: IPC-валидация, persistence (миграции/карантин/cap), scheduler-gating
(тихие часы/ВКС/daily-goal), детект ВКС (мок 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,8 +2,8 @@
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)
[![tests](https://img.shields.io/badge/tests-135%20passing-green)]()
[![release](https://img.shields.io/badge/release-v0.5.8-orange)](https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/latest)
[![tests](https://img.shields.io/badge/tests-203%20passing-green)]()
[![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]()
## Что внутри
@@ -34,7 +34,7 @@ Windows SmartScreen может предупредить «не доверено
## Разработка
```bash
git clone https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude.git
git clone https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude.git
cd laude
npm install
npm run dev
@@ -66,21 +66,32 @@ npm run release -- -Bump patch # bump версии + tag + push + upload в G
## Тесты
```
src/shared/types.test.ts (4)
src/shared/quiet-hours.test.ts (5)
src/renderer/src/lib/format.test.ts (8)
src/renderer/src/lib/history.test.ts (13)
src/main/games/vdf.test.ts (11)
src/renderer/src/i18n/i18n.test.ts (10)
─────────────────────────────────────────
51 ✓
src/main/validate.test.ts (68)
src/renderer/src/lib/history.test.ts (31)
src/renderer/src/i18n/i18n.test.ts (15)
src/renderer/src/lib/format.test.ts (14)
src/main/games/vdf.test.ts (11)
src/main/store.test.ts (10)
src/renderer/src/lib/achievements.test.ts (10)
src/shared/release-notes.test.ts (9)
src/main/scheduler.test.ts (8)
src/main/meeting-detect.test.ts (7)
src/shared/quiet-hours.test.ts (7)
src/main/adaptive.test.ts (6)
src/shared/types.test.ts (4)
src/renderer/src/lib/icon-choices.test.ts (3)
──────────────────────────────────────────
203 ✓
```
Покрытие: чистые helpers (форматирование, история/стрики, тихие часы, парсер VDF для Steam-конфигов), i18n с плюрализацией для RU/EN, дефолты shared-типов.
Покрытие: IPC-валидация, persistence (миграции, карантин битого JSON, history cap), scheduler-гейтинг (тихие часы, ВКС-пауза, daily-goal), детект ВКС, история/стрики (DST), тихие часы (wrap), парсер VDF для Steam-конфигов, достижения, i18n с плюрализацией RU/EN, дефолты shared-типов.
## Лицензия
Пока не указана. По умолчанию все права защищены. Если хочешь форк/использование — открой issue.
Проприетарная — все права защищены. Личное некоммерческое использование
разрешено; копирование, распространение, модификация и реверс-инжиниринг — без
письменного разрешения автора. Полный текст — в файле [LICENSE](LICENSE). По
вопросам использования за рамками лицензии открой issue в репозитории.
## Stack

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -7,6 +7,7 @@ import {
dialog,
shell
} from 'electron'
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
import { readFileSync, writeFileSync } from 'node:fs'
import { IPC } from '@shared/ipc'
import type { Exercise, GameId, Settings } from '@shared/types'
@@ -21,6 +22,7 @@ import {
getState,
getStateForRenderer,
importState,
markChallengeDone,
markDone,
setGameEnabled,
skip,
@@ -29,9 +31,9 @@ import {
updateExercise,
updateSettings
} from './store'
import { broadcastState } from './state-actions'
import { broadcastHistoryChanged, broadcastState } from './state-actions'
import { setAutostart, isAutostartEnabled } from './autostart'
import { setPaused, forceCheck } from './scheduler'
import { forceCheck } from './scheduler'
import { hideReminderWindow, getMainWindow } from './windows'
import { refreshMenu } from './tray'
import {
@@ -48,6 +50,7 @@ import {
getUpdaterStatus,
quitAndInstall
} from './updater'
import { isMeetingActiveSync } from './meeting-detect'
import {
validateActualReps,
validateChallengeInput,
@@ -58,9 +61,57 @@ import {
validateSettingsPatch,
validateSnoozeMinutes
} from './validate'
import { log } from './logger'
/**
* Враппер вокруг `ipcMain.handle`: ловит любое исключение в обработчике,
* пишет его в лог и отдаёт renderer'у обобщённую ошибку. Без этого один
* упавший хендлер молча обрывает invoke (renderer висит на await) и в проде
* не остаётся следов. Generic-сообщение наружу — не утекают внутренние детали.
*
* Констрейнт `...args: never[]` делает любую сигнатуру хендлера присваиваемой
* (контравариантность параметров), поэтому типы на call-site сохраняются.
*/
function safeHandle<
F extends (event: IpcMainInvokeEvent, ...args: never[]) => unknown
>(channel: string, fn: F): void {
ipcMain.handle(channel, async (event, ...args) => {
try {
const call = fn as unknown as (
e: IpcMainInvokeEvent,
...a: unknown[]
) => unknown
return await call(event, ...args)
} catch (err) {
log.error(`[ipc] ${channel} threw`, err)
throw new Error('ipc-failed')
}
})
}
/**
* Аналог для `ipcMain.on` (fire-and-forget). Ошибку логируем, но не
* пробрасываем — у sender'а нет канала для ответа.
*/
function safeOn<F extends (event: IpcMainEvent, ...args: never[]) => void>(
channel: string,
fn: F
): void {
ipcMain.on(channel, (event, ...args) => {
try {
const call = fn as unknown as (
e: IpcMainEvent,
...a: unknown[]
) => void
call(event, ...args)
} catch (err) {
log.error(`[ipc] ${channel} (on) threw`, err)
}
})
}
export function registerIpc(): void {
ipcMain.handle(IPC.getState, () => {
safeHandle(IPC.getState, () => {
// Без history (см. getStateForRenderer) и с актуальным значением
// autostart из OS — мутацию делаем по копии, не по cache.
const state = getStateForRenderer()
@@ -71,7 +122,7 @@ export function registerIpc(): void {
return state
})
ipcMain.handle(IPC.addExercise, (_e, input: unknown) => {
safeHandle(IPC.addExercise, (_e, input: unknown) => {
const safe = validateExerciseInput(input)
if (!safe) return null
const ex = addExercise(safe)
@@ -79,7 +130,7 @@ export function registerIpc(): void {
return ex
})
ipcMain.handle(
safeHandle(
IPC.updateExercise,
(_e, idRaw: unknown, patchRaw: unknown) => {
const id = validateId(idRaw)
@@ -91,7 +142,7 @@ export function registerIpc(): void {
}
)
ipcMain.handle(IPC.deleteExercise, (_e, idRaw: unknown) => {
safeHandle(IPC.deleteExercise, (_e, idRaw: unknown) => {
const id = validateId(idRaw)
if (!id) return false
const ok = deleteExercise(id)
@@ -99,7 +150,7 @@ export function registerIpc(): void {
return ok
})
ipcMain.handle(
safeHandle(
IPC.toggleExercise,
(_e, idRaw: unknown, enabledRaw: unknown) => {
const id = validateId(idRaw)
@@ -115,32 +166,35 @@ export function registerIpc(): void {
}
)
ipcMain.handle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => {
safeHandle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => {
const id = validateId(idRaw)
if (!id) return null
const ex = markDone(id, validateActualReps(repsRaw))
broadcastState()
broadcastHistoryChanged()
return ex
})
ipcMain.handle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => {
safeHandle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => {
const id = validateId(idRaw)
const minutes = validateSnoozeMinutes(minRaw)
if (!id || minutes === null) return null
const ex = snooze(id, minutes)
broadcastState()
broadcastHistoryChanged()
return ex
})
ipcMain.handle(IPC.skip, (_e, idRaw: unknown) => {
safeHandle(IPC.skip, (_e, idRaw: unknown) => {
const id = validateId(idRaw)
if (!id) return null
const ex = skip(id)
broadcastState()
broadcastHistoryChanged()
return ex
})
ipcMain.handle(IPC.updateSettings, (_e, patchRaw: unknown) => {
safeHandle(IPC.updateSettings, (_e, patchRaw: unknown) => {
const patch = validateSettingsPatch(patchRaw)
if (!patch) return null
if (patch.startWithWindows !== undefined) {
@@ -152,22 +206,27 @@ export function registerIpc(): void {
}
const settings = updateSettings(merged)
broadcastState()
// Language change reflects in the tray menu next time it's opened.
if (patch.language !== undefined) refreshMenu()
// Tray-menu label «Пауза/Возобновить» зависит от globalEnabled — рефреш.
// А также language change.
if (patch.language !== undefined || patch.globalEnabled !== undefined) {
refreshMenu()
}
return settings
})
ipcMain.handle(IPC.pauseAll, () => {
setPaused(true)
safeHandle(IPC.pauseAll, () => {
updateSettings({ globalEnabled: false })
broadcastState()
refreshMenu()
})
ipcMain.handle(IPC.resumeAll, () => {
setPaused(false)
safeHandle(IPC.resumeAll, () => {
updateSettings({ globalEnabled: true })
broadcastState()
forceCheck()
refreshMenu()
})
ipcMain.handle(IPC.getAccentColor, () => {
safeHandle(IPC.getAccentColor, () => {
try {
return '#' + systemPreferences.getAccentColor()
} catch {
@@ -175,43 +234,45 @@ export function registerIpc(): void {
}
})
ipcMain.handle(IPC.getOsTheme, () =>
safeHandle(IPC.getOsTheme, () =>
nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
)
ipcMain.handle(IPC.getAppVersion, () => app.getVersion())
safeHandle(IPC.getAppVersion, () => app.getVersion())
ipcMain.handle(IPC.quit, () => app.quit())
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
safeHandle(IPC.getMeetingActive, () => isMeetingActiveSync())
ipcMain.on(IPC.minimizeMain, (event) => {
safeHandle(IPC.quit, () => app.quit())
safeHandle(IPC.reminderClose, () => hideReminderWindow())
safeOn(IPC.minimizeMain, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
})
ipcMain.on(IPC.toggleMaximizeMain, (event) => {
safeOn(IPC.toggleMaximizeMain, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
if (win.isMaximized()) win.unmaximize()
else win.maximize()
})
ipcMain.handle(IPC.isMaximizedMain, (event) => {
safeHandle(IPC.isMaximizedMain, (event) => {
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false
})
ipcMain.on(IPC.closeMain, () => {
safeOn(IPC.closeMain, () => {
const main = getMainWindow()
if (!main) return
if (getState().settings.minimizeToTray) main.hide()
else main.close()
})
ipcMain.on(IPC.hideMain, () => getMainWindow()?.hide())
safeOn(IPC.hideMain, () => getMainWindow()?.hide())
// Games
ipcMain.handle(IPC.gamesList, async () => listGamesStatus())
safeHandle(IPC.gamesList, async () => listGamesStatus())
ipcMain.handle(IPC.gameInstall, async (_e, id: GameId) => {
safeHandle(IPC.gameInstall, async (_e, id: GameId) => {
const status = await installGame(id)
setGameEnabled(id, true)
await toggleGame(id, true)
@@ -221,7 +282,7 @@ export function registerIpc(): void {
return status
})
ipcMain.handle(IPC.gameUninstall, async (_e, id: GameId) => {
safeHandle(IPC.gameUninstall, async (_e, id: GameId) => {
const status = await uninstallGame(id)
setGameEnabled(id, false)
const all = await listGamesStatus()
@@ -230,7 +291,7 @@ export function registerIpc(): void {
return status
})
ipcMain.handle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
safeHandle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
setGameEnabled(id, enabled)
await toggleGame(id, enabled)
const all = await listGamesStatus()
@@ -238,20 +299,20 @@ export function registerIpc(): void {
broadcastState()
})
ipcMain.handle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
safeHandle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
// Opens Steam's library; user manually adds launch options.
shell.openExternal('steam://nav/games/details/570')
})
// Challenges
ipcMain.handle(IPC.addChallenge, (_e, input: unknown) => {
safeHandle(IPC.addChallenge, (_e, input: unknown) => {
const safe = validateChallengeInput(input)
if (!safe) return null
const c = addChallenge(safe)
broadcastState()
return c
})
ipcMain.handle(
safeHandle(
IPC.updateChallenge,
(_e, idRaw: unknown, patchRaw: unknown) => {
const id = validateId(idRaw)
@@ -262,14 +323,14 @@ export function registerIpc(): void {
return c
}
)
ipcMain.handle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
safeHandle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
const id = validateId(idRaw)
if (!id) return false
const ok = deleteChallenge(id)
broadcastState()
return ok
})
ipcMain.handle(
safeHandle(
IPC.toggleChallenge,
(_e, idRaw: unknown, enabledRaw: unknown) => {
const id = validateId(idRaw)
@@ -280,13 +341,26 @@ export function registerIpc(): void {
}
)
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
// packaged builds — a compromised renderer (XSS, malicious npm dep) could
// otherwise fabricate arbitrary match-end events at will.
if (!app.isPackaged) {
ipcMain.handle(
safeHandle(
IPC.devSimulateMatchEnd,
(_e, id: GameId, stats: Record<string, number>) => {
simulateMatchEnd(id, stats)
@@ -295,62 +369,77 @@ export function registerIpc(): void {
}
// Auto-updater
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus())
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
safeHandle(IPC.updaterStatus, () => getUpdaterStatus())
safeHandle(IPC.updaterCheck, () => checkForUpdates())
// download/install — fire-and-forget. Прогресс и завершение приходят в
// renderer через evtUpdaterStatus, ждать promise бессмысленно — renderer
// только зря держал бы `busy=true` весь download (минуты на медленной сети).
ipcMain.on(IPC.updaterDownload, () => {
safeOn(IPC.updaterDownload, () => {
void downloadUpdate()
})
ipcMain.on(IPC.updaterInstall, () => quitAndInstall())
safeOn(IPC.updaterInstall, () => quitAndInstall())
// History
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) =>
clearHistory(beforeTs)
)
safeHandle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
safeHandle(IPC.clearHistory, (_e, beforeTs?: number) => {
const removed = clearHistory(beforeTs)
if (removed > 0) broadcastHistoryChanged()
return removed
})
// Export / Import. Используем native save/open dialogs Electron'а
// renderer не получает прямого доступа к ФС.
ipcMain.handle(IPC.exportState, async (event) => {
safeHandle(IPC.exportState, async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
const stamp = new Date()
.toISOString()
.replace(/[:T]/g, '-')
.slice(0, 16)
const defaultPath = `laude-backup-${stamp}.json`
// Native-диалоги OS читают локаль из системы. Title — единственная
// строка которую мы контролируем; локализуем по settings.language.
const lang = getState().settings.language ?? 'ru'
const result = await dialog.showSaveDialog(win!, {
title: 'Сохранить резервную копию',
title:
lang === 'en' ? 'Save backup' : 'Сохранить резервную копию',
defaultPath,
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || !result.filePath) return { ok: false, path: null }
// Cancel — это не ошибка. Возвращаем canceled=true чтобы UI мог
// ничего не показывать (без error toast).
if (result.canceled || !result.filePath) {
return { ok: false, canceled: true, path: null }
}
try {
writeFileSync(result.filePath, exportState(), 'utf-8')
return { ok: true, path: result.filePath }
return { ok: true, canceled: false, path: result.filePath }
} catch (e) {
return { ok: false, path: null, error: String(e) }
return { ok: false, canceled: false, path: null, error: String(e) }
}
})
ipcMain.handle(IPC.importState, async (event) => {
safeHandle(IPC.importState, async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
const lang = getState().settings.language ?? 'ru'
const result = await dialog.showOpenDialog(win!, {
title: 'Восстановить из резервной копии',
title:
lang === 'en' ? 'Restore from backup' : 'Восстановить из резервной копии',
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || result.filePaths.length === 0) {
return { ok: false }
return { ok: false, canceled: true }
}
try {
const raw = readFileSync(result.filePaths[0], 'utf-8')
const ok = importState(raw)
if (ok) broadcastState()
return { ok }
if (ok) {
broadcastState()
broadcastHistoryChanged()
}
return { ok, canceled: false }
} catch (e) {
return { ok: false, error: String(e) }
return { ok: false, canceled: false, error: String(e) }
}
})
}

View File

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

View File

@@ -16,8 +16,16 @@
*/
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc'
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)
/**
@@ -56,7 +64,12 @@ export async function isMeetingActive(): Promise<boolean> {
// CSV без заголовков (/NH), скрытое окно.
const { stdout } = await execAsync('tasklist /FO CSV /NH', {
windowsHide: true,
maxBuffer: 4 * 1024 * 1024 // tasklist бывает большой
maxBuffer: 4 * 1024 * 1024, // tasklist бывает большой
// Если tasklist подвис (повреждённый WMI, загруженная система) — exec
// сам прибьёт процесс и уйдёт в catch. Без таймаута зависшие child
// накапливались бы при каждом refresh.
timeout: 4000,
killSignal: 'SIGKILL'
})
const lower = stdout.toLowerCase()
for (const proc of MEETING_PROCESSES) {
@@ -65,6 +78,7 @@ export async function isMeetingActive(): Promise<boolean> {
if (lower.includes(`"${proc}",`)) {
if (!cachedActive) {
log.info(`[meeting] detected ${proc} — pausing reminders`)
broadcast(true)
}
cachedActive = true
return true
@@ -72,6 +86,7 @@ export async function isMeetingActive(): Promise<boolean> {
}
if (cachedActive) {
log.info('[meeting] no meeting processes — resuming reminders')
broadcast(false)
}
cachedActive = false
return false

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

@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type {
Exercise,
HistoryEntry,
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[],
history: [] as HistoryEntry[],
meetingActive: false,
fireReminder: vi.fn(),
updateExercise: 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,
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
}
}))
vi.mock('./notifications', () => ({ fireReminder: h.fireReminder }))
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')
}
beforeEach(() => {
vi.resetModules()
h.settings = { ...DEFAULT_SETTINGS }
h.exercises = []
h.history = []
h.meetingActive = false
h.fireReminder.mockClear()
h.updateExercise.mockClear()
h.broadcastState.mockClear()
h.refreshMeetingState.mockClear()
h.adjustNextFireAt.mockClear()
})
describe('checkDueExercises gating', () => {
it('не fire-ит когда globalEnabled=false', async () => {
h.settings = { ...DEFAULT_SETTINGS, globalEnabled: false }
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('не fire-ит внутри тихих часов', async () => {
h.settings = { ...DEFAULT_SETTINGS, quietHours: quietWindowAroundNow() }
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('не fire-ит когда активна ВКС (meetingAutoPause)', async () => {
h.settings = { ...DEFAULT_SETTINGS, meetingAutoPause: true }
h.meetingActive = true
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.refreshMeetingState).toHaveBeenCalled()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('fire-ит готовое к срабатыванию упражнение и шлёт broadcastState', async () => {
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).toHaveBeenCalledTimes(1)
expect(h.broadcastState).toHaveBeenCalled()
})
it('пропускает выключенные упражнения', async () => {
h.exercises = [makeExercise({ enabled: false })]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('не fire-ит упражнение, чьё время ещё не пришло', async () => {
h.exercises = [makeExercise({ nextFireAt: Date.now() + 60_000 })]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('soft-cap: при закрытой dailyGoal переносит fire, но не показывает', async () => {
h.exercises = [makeExercise({ dailyGoal: 20 })]
h.history = [
{
ts: Date.now(),
exerciseId: 'ex1',
action: 'done',
actualReps: 25
}
]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
// nextFireAt перенесён (на завтра) — updateExercise вызван.
expect(h.updateExercise).toHaveBeenCalled()
})
it('adaptive: применяет adjustNextFireAt к кандидату', async () => {
h.exercises = [makeExercise({ adaptive: true })]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.adjustNextFireAt).toHaveBeenCalled()
expect(h.fireReminder).toHaveBeenCalledTimes(1)
})
})

View File

@@ -36,10 +36,8 @@ const CHECK_MS = 5000
let tickHandle: NodeJS.Timeout | null = null
let powerListenersArmed = false
let lastCheckAt = 0
let paused = false
function checkDueExercises(): void {
if (paused) return
const settings = getSettings()
if (!settings.globalEnabled) return
@@ -148,14 +146,6 @@ export function stopScheduler(): void {
}
}
export function setPaused(value: boolean): void {
paused = value
}
export function isPaused(): boolean {
return paused
}
export function forceCheck(): void {
lastCheckAt = 0
tick()

View File

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

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

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

View File

@@ -199,20 +199,32 @@ function load(): PersistedState {
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(
exerciseId: string,
action: HistoryAction,
actualReps?: number
opts: AppendOpts = {}
): void {
const state = getState()
if (!state.history) state.history = []
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)
if (state.history.length > HISTORY_MAX) {
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[] {
@@ -291,11 +303,11 @@ function atomicWriteSync(path: string, contents: string): void {
}
const delay = WRITE_RETRY_DELAYS[i]
if (delay === undefined) break
// Event-loop остановлен, async sleep не вернётся — приходится spin.
const until = Date.now() + delay
while (Date.now() < until) {
/* spin */
}
// Event-loop остановлен (exit-path), async sleep не вернётся — нужен
// блокирующий sync sleep. Atomics.wait на «свежем» буфере всегда уходит
// в таймаут (значение совпадает с ожидаемым 0), т.е. честно спит delay мс
// без сжигания CPU — в отличие от старого busy-loop.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
}
}
log.error('[store] atomic sync write failed after retries', lastErr)
@@ -425,7 +437,12 @@ export function markDone(
if (!ex) return undefined
ex.lastDoneAt = Date.now()
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()
return ex
}
@@ -435,7 +452,7 @@ export function snooze(id: string, minutes: number): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined
ex.nextFireAt = Date.now() + minutes * 60_000
appendHistory(id, 'snooze')
appendHistory(id, 'snooze', { reps: ex.reps, name: ex.name })
scheduleWrite()
return ex
}
@@ -445,11 +462,28 @@ export function skip(id: string): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
appendHistory(id, 'skip')
appendHistory(id, 'skip', { reps: ex.reps, name: ex.name })
scheduleWrite()
return ex
}
/**
* Записать выполнение челленджа из 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 {
if (pendingWrite) {
clearTimeout(pendingWrite)

View File

@@ -1,9 +1,9 @@
import { Tray, Menu, nativeImage, app } from 'electron'
import { join } from 'node:path'
import { showMainWindow } from './windows'
import { isPaused, setPaused, forceCheck } from './scheduler'
import { snoozeAll } from './state-actions'
import { getSettings } from './store'
import { forceCheck } from './scheduler'
import { broadcastState, snoozeAll } from './state-actions'
import { getSettings, updateSettings } from './store'
import type { Language } from '@shared/types'
let tray: Tray | null = null
@@ -69,16 +69,21 @@ export function createTray(): Tray {
export function refreshMenu(): void {
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([
{ label: trayLabel('open'), click: () => showMainWindow() },
{ type: 'separator' },
{
label: paused ? trayLabel('resume') : trayLabel('pause'),
click: () => {
setPaused(!paused)
updateSettings({ globalEnabled: paused }) // toggle
broadcastState() // чтобы Dashboard перерисовал кнопку сразу
refreshMenu()
if (!paused) forceCheck()
if (paused) forceCheck() // resuming — догнать пропущенные fires
}
},
{

View File

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

View File

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

View File

@@ -48,6 +48,8 @@ const api = {
getOsTheme: (): Promise<'light' | 'dark'> =>
ipcRenderer.invoke(IPC.getOsTheme),
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.getAppVersion),
getMeetingActive: (): Promise<boolean> =>
ipcRenderer.invoke(IPC.getMeetingActive),
pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll),
resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll),
@@ -83,6 +85,8 @@ const api = {
ipcRenderer.invoke(IPC.deleteChallenge, id),
toggleChallenge: (id: string, enabled: boolean): Promise<Challenge> =>
ipcRenderer.invoke(IPC.toggleChallenge, id, enabled),
markChallengeDone: (id: string, reps: number): Promise<boolean> =>
ipcRenderer.invoke(IPC.markChallengeDone, id, reps),
closeMatchSummary: (): Promise<void> =>
ipcRenderer.invoke(IPC.closeMatchSummary),
@@ -118,10 +122,17 @@ const api = {
ipcRenderer.invoke(IPC.clearHistory, beforeTs),
// Export / Import — открывают native save/open dialogs из main process.
exportState: (): Promise<{ ok: boolean; path: string | null }> =>
ipcRenderer.invoke(IPC.exportState),
importState: (): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke(IPC.importState),
exportState: (): Promise<{
ok: boolean
canceled: boolean
path: string | null
error?: string
}> => ipcRenderer.invoke(IPC.exportState),
importState: (): Promise<{
ok: boolean
canceled: boolean
error?: string
}> => ipcRenderer.invoke(IPC.importState),
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
@@ -135,7 +146,11 @@ const api = {
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
on(IPC.evtUpdaterStatus, h),
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)

View File

@@ -5,6 +5,7 @@ import { Sidebar } from './components/Sidebar'
import { Titlebar } from './components/Titlebar'
import { ErrorBoundary } from './components/ErrorBoundary'
import { WhatsNewModal } from './components/WhatsNewModal'
import { Skeleton } from './components/ui/Skeleton'
import { unseenVersions } from '@shared/release-notes'
import Dashboard from './pages/Dashboard'
import Exercises from './pages/Exercises'
@@ -36,19 +37,31 @@ export default function App(): JSX.Element {
}
}, [])
// После хидрации сверяем текущую версию приложения с lastSeenVersion.
// Если первая хидрация и lastSeenVersion ещё не записан — это либо
// первый запуск, либо обновление со старой версии (где поля не было) —
// в любом случае пишем текущую версию и НЕ показываем модалку (мы не
// хотим бить нового пользователя CHANGELOG'ом).
// Если lastSeenVersion есть и не совпадает с current → показываем.
// Различаем три кейса по `lastSeenVersion`:
// 1) есть и !== current → классический update path, показываем
// пропущенные заметки.
// 2) есть и === current → ничего не делаем.
// 3) нет (undefined) → это ИЛИ первый запуск нового пользователя,
// ИЛИ существующий пользователь, который апгрейдится с версии,
// где поля ещё не было (всё < 0.5.6).
// Разрешаем эту неоднозначность через proxy «уже пользовался
// приложением» — хотя бы одно упражнение имеет `lastDoneAt`.
// Новичкам тихо записываем; обновляющимся — показываем заметки
// текущей версии, чтобы они узнали про новые фичи.
useEffect(() => {
if (!hydrated || !settings) return
const exercises = useAppStore.getState().state?.exercises ?? []
const isExistingUser = exercises.some((e) => e.lastDoneAt !== undefined)
void window.api.getAppVersion().then((current) => {
const last = settings.lastSeenVersion
if (!last) {
// Первая запись — сохраняем тихо.
window.api.updateSettings({ lastSeenVersion: current })
if (isExistingUser) {
// Обновляющийся — показываем заметки текущей версии.
setWhatsNew({ open: true, versions: [current] })
} else {
// Новый — тихо записываем, не отвлекаем.
window.api.updateSettings({ lastSeenVersion: current })
}
return
}
if (last !== current) {
@@ -56,7 +69,6 @@ export default function App(): JSX.Element {
if (versions.length > 0) {
setWhatsNew({ open: true, versions })
} else {
// Версии есть, заметок нет — просто обновляем.
window.api.updateSettings({ lastSeenVersion: current })
}
}
@@ -89,8 +101,23 @@ export default function App(): JSX.Element {
<RoutedPages onNav={() => setMobileNavOpen(false)} />
</ErrorBoundary>
) : (
// Neutral placeholder — settings (and lang) aren't loaded yet.
<div className="p-8 text-text/45" />
// Skeleton на время гидрации — settings (и язык) ещё не
// загружены, текст показывать рано, но пустота выглядит как
// зависание. Каркас задаёт ожидание «сейчас появится контент».
<div
className="p-6 sm:p-8 space-y-5 max-w-3xl"
role="status"
aria-label="Loading"
>
<Skeleton className="h-9 w-48" />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-40" />
<Skeleton className="h-28" />
</div>
)}
</main>
</div>

View File

@@ -28,10 +28,21 @@ type Mode =
| { kind: 'exercise'; exercise: Exercise }
| { kind: 'match'; summary: MatchSummary; done: Set<string> }
/** Минимальный нативный 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 {
const [mode, setMode] = useState<Mode>({ kind: 'idle' })
const [settings, setSettings] = useState<Settings | null>(null)
const settingsRef = useRef<Settings | null>(null)
// ChallengeId'ы, для которых уже отправили markChallengeDone IPC. ref,
// не state — нужен только для дедупа rapid double-click. Сбрасывается
// когда приходит новый match summary (см. onMatchEnd ниже).
const sentChallengesRef = useRef<Set<string>>(new Set())
useEffect(() => {
settingsRef.current = settings
@@ -45,17 +56,22 @@ export default function ReminderApp(): JSX.Element {
const s = settingsRef.current
if (s?.soundEnabled) playBeep()
if (s?.voicePromptsEnabled) {
// «{exercise.name}, {n} раз/раза/раз». Простая локальная фраза без
// ключа в dict — короткая команда, не нуждается в полном переводе.
// Задержка 800ms даёт пользователю шанс decrement'нуть stepper до
// фактического количества — TTS прозвучит уже под реальную цифру,
// если успел нажать -. Иначе скажет планируемые reps.
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)
setTimeout(() => {
const phrase =
lang === 'ru'
? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}`
: `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}`
speak(phrase, lang)
}, 800)
}
})
const u2 = window.api.onMatchEnd((summary) => {
// Новый матч — сбрасываем дедуп challenge'ей.
sentChallengesRef.current = new Set()
setMode({ kind: 'match', summary, done: new Set() })
const s = settingsRef.current
if (s?.soundEnabled) playBeep()
@@ -88,6 +104,27 @@ export default function ReminderApp(): JSX.Element {
}, [mode.kind])
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
}
}
setMode({ kind: 'idle' })
window.api.reminderClose()
}
@@ -113,9 +150,19 @@ export default function ReminderApp(): JSX.Element {
summary={mode.summary}
done={mode.done}
lang={lang}
onMarkDone={(id) =>
// Functional update so a second rapid click can't race against a stale
// `mode.done` captured in this closure.
onMarkDone={(id) => {
// Дедупликация: rapid double-click может два раза вызвать
// onMarkDone до того как `disabled={done}` доедет до DOM.
// Раньше это писало в историю дважды → лишние +N reps.
if (sentChallengesRef.current.has(id)) return
sentChallengesRef.current.add(id)
// 1) IPC: записываем в историю (раньше делали только локальный set,
// из-за чего матч-челленджи не считались в стрик/achievements).
const result = mode.summary.results.find((r) => r.challengeId === id)
if (result && result.reps > 0) {
void window.api.markChallengeDone(id, result.reps)
}
// 2) Functional update: rapid-click race-safe.
setMode((m) =>
m.kind === 'match'
? {
@@ -125,7 +172,7 @@ export default function ReminderApp(): JSX.Element {
}
: m
)
}
}}
onClose={close}
/>
)

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 type { Exercise, HistoryEntry } from '@shared/types'
import {
@@ -6,6 +7,28 @@ import {
type AchievementProgress
} from '../lib/achievements'
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 = {
Activity,
@@ -26,12 +49,47 @@ type Props = {
*/
export function AchievementsCard({ history, exercises }: Props): JSX.Element {
const { t } = useT()
const announce = useAnnounce()
const achievements = useMemo(
() => computeAchievements(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 locked = achievements.filter((a) => !a.unlocked)
// Сортируем locked по близости к unlock'у — чтобы «осталось 12»
@@ -67,14 +125,24 @@ export function AchievementsCard({ history, exercises }: Props): JSX.Element {
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{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>
)
}
function Badge({ a }: { a: AchievementProgress }): JSX.Element {
function Badge({
a,
fresh
}: {
a: AchievementProgress
fresh: boolean
}): JSX.Element {
const { t } = useT()
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))
@@ -86,7 +154,28 @@ function Badge({ a }: { a: AchievementProgress }): JSX.Element {
}[a.def.tone]
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={[
'rounded-xl p-2.5 transition-opacity',
a.unlocked ? 'bg-surface-2' : 'bg-surface-2 opacity-55'
@@ -123,6 +212,6 @@ function Badge({ a }: { a: AchievementProgress }): JSX.Element {
</div>
</>
)}
</div>
</motion.div>
)
}

View File

@@ -1,15 +1,18 @@
import { motion } from 'framer-motion'
import { Check, MoreHorizontal } from 'lucide-react'
import { useState } from 'react'
import { Check, MoreHorizontal, Brain, CheckCircle2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon'
import { formatCountdown } from '../lib/format'
import { Switch } from './ui/Switch'
import { useT } from '../i18n'
import { useAnnounce } from '../lib/useAnnounce'
type Props = {
exercise: Exercise
tick?: Tick
/** Сделано повторений сегодня (для daily-goal индикатора). */
doneToday?: number
onEdit: () => void
onDelete: () => void
onToggle: (enabled: boolean) => void
@@ -24,6 +27,7 @@ type Props = {
export function ExerciseCard({
exercise,
tick,
doneToday,
onEdit,
onDelete,
onToggle,
@@ -33,9 +37,71 @@ export function ExerciseCard({
const total = exercise.intervalMinutes * 60_000
const remaining = Math.max(0, Math.min(total, ms))
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 triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// При открытии меню переводим фокус на первый пункт — клавиатурный
// пользователь сразу внутри, без слепого Tab'а.
useEffect(() => {
if (!menuOpen) return
menuRef.current
?.querySelector<HTMLButtonElement>('[role="menuitem"]')
?.focus()
}, [menuOpen])
// Esc закрывает и возвращает фокус на триггер; стрелки/Home/End — навигация.
const onMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
const items = Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]'
) ?? []
)
if (items.length === 0) return
const idx = items.indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'Escape') {
e.preventDefault()
setMenuOpen(false)
triggerRef.current?.focus()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
items[(idx + 1) % items.length]?.focus()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
items[(idx - 1 + items.length) % items.length]?.focus()
} else if (e.key === 'Home') {
e.preventDefault()
items[0]?.focus()
} else if (e.key === 'End') {
e.preventDefault()
items[items.length - 1]?.focus()
}
}
// Дедуп rapid double-click на «Готово». Между кликом и обновлением
// nextFireAt (через broadcastState) есть окно ~1 сек, в которое можно
// вызвать markDone повторно и записать лишний entry в историю.
const markDoneInFlightRef = useRef(false)
const { t, lang } = useT()
const announce = useAnnounce()
const handleMarkDone = (): void => {
if (markDoneInFlightRef.current) return
markDoneInFlightRef.current = true
onMarkDone()
// Озвучиваем для screen-reader'ов — кнопка после засчёта исчезает,
// визуальный feedback незрячему недоступен.
announce(`${t('btn.done')}: ${exercise.name}`)
// К моменту окончания таймаута isDue уже false (после store-tick), кнопка
// не рендерится — флаг чистим на всякий случай для будущих кейсов.
setTimeout(() => {
markDoneInFlightRef.current = false
}, 1000)
}
// Ring math
const R = 22
@@ -102,9 +168,12 @@ export function ExerciseCard({
</h3>
<div className="relative">
<button
ref={triggerRef}
onClick={() => setMenuOpen((v) => !v)}
className="w-7 h-7 grid place-items-center rounded-full text-text/45 hover:bg-surface-2 active:scale-90 transition-all"
aria-label={t('titlebar.menu_aria')}
aria-haspopup="menu"
aria-expanded={menuOpen}
>
<MoreHorizontal size={16} />
</button>
@@ -114,8 +183,15 @@ export function ExerciseCard({
className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 top-8 z-20 min-w-[140px] bg-surface rounded-xl shadow-sheet ring-0.5 ring-hairline/30 py-1 overflow-hidden">
<div
ref={menuRef}
role="menu"
aria-label={exercise.name}
onKeyDown={onMenuKeyDown}
className="absolute right-0 top-8 z-20 min-w-[140px] bg-surface rounded-xl shadow-sheet ring-0.5 ring-hairline/30 py-1 overflow-hidden"
>
<button
role="menuitem"
onClick={() => {
setMenuOpen(false)
onEdit()
@@ -125,6 +201,7 @@ export function ExerciseCard({
{t('btn.edit')}
</button>
<button
role="menuitem"
onClick={() => {
setMenuOpen(false)
onDelete()
@@ -148,16 +225,48 @@ export function ExerciseCard({
{/* Countdown + switch */}
<div className="flex items-end justify-between mt-3.5">
<div>
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold">
{isDue ? t('dashboard.stat.next.now') : t('fmt.through')}
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold inline-flex items-center gap-1">
{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
className={[
'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(' ')}
>
{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>
<Switch
@@ -173,7 +282,7 @@ export function ExerciseCard({
<motion.button
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
onClick={onMarkDone}
onClick={handleMarkDone}
className="mt-4 w-full h-11 rounded-xl bg-accent text-white text-[15px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
<Check size={15} strokeWidth={2.5} /> {t('btn.done')}

View File

@@ -0,0 +1,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

@@ -31,6 +31,15 @@ export const ru: Dict = {
'btn.add': 'Добавить',
'btn.new': 'Новый',
'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.done': 'Готово',
'btn.start': 'Старт',
@@ -65,10 +74,16 @@ export const ru: Dict = {
'dashboard.stat.tracking': 'Трекинг матчей',
'dashboard.stat.tracking.on': 'On',
'dashboard.stat.tracking.off': 'Off',
'dashboard.stat.tracking.pending': 'Setup',
'dashboard.stat.tracking.subtitle_on': 'в реальном времени',
'dashboard.stat.tracking.subtitle_off': 'выключен',
'dashboard.stat.tracking.subtitle_pending':
'нужно закрыть Steam и снова открыть',
'dashboard.paused.title': 'Напоминания на паузе',
'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт',
'dashboard.meeting.title': 'Не дёргаем — ты на встрече',
'dashboard.meeting.hint':
'Запущен Zoom / Teams / Discord / Webex / Slack-huddle. Напоминания возобновятся когда закроешь.',
'dashboard.empty.title': 'Программа пуста',
'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать',
@@ -159,6 +174,8 @@ export const ru: Dict = {
'settings.data.import.ok': 'Восстановлено',
'settings.data.import.err': 'Файл не подошёл — это не наша резервная копия?',
'settings.section.about': 'О приложении',
'settings.version.label': 'Версия',
'settings.version.hint': 'Текущая установленная версия приложения.',
'settings.whatsnew.label': 'Что нового',
'settings.whatsnew.hint': 'Посмотреть заметки последних релизов.',
'settings.whatsnew.btn': 'Открыть',
@@ -233,6 +250,7 @@ export const ru: Dict = {
'achievements.title': 'Достижения',
'achievements.unlocked_of': '{n} из {total}',
'achievements.progress': 'осталось {n}',
'achievements.announce': 'Достижение получено: {title}',
'achievement.reps.desc': 'Сделай {target} повторений всего',
'achievement.reps_100.title': 'Сотня',
'achievement.reps_500.title': 'Пятьсот',
@@ -346,6 +364,15 @@ export const en: Dict = {
'btn.add': 'Add',
'btn.new': 'New',
'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.done': 'Done',
'btn.start': 'Start',
@@ -380,9 +407,14 @@ export const en: Dict = {
'dashboard.stat.tracking': 'Match tracking',
'dashboard.stat.tracking.on': 'On',
'dashboard.stat.tracking.off': 'Off',
'dashboard.stat.tracking.pending': 'Setup',
'dashboard.stat.tracking.subtitle_on': 'real-time',
'dashboard.stat.tracking.subtitle_off': 'disabled',
'dashboard.stat.tracking.subtitle_pending': 'close & reopen Steam',
'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.empty.title': 'Program is empty',
'dashboard.empty.hint': 'Add your first exercise to start',
@@ -474,6 +506,8 @@ export const en: Dict = {
'settings.data.import.ok': 'Restored',
'settings.data.import.err': "Couldn't read the file — not our backup?",
'settings.section.about': 'About',
'settings.version.label': 'Version',
'settings.version.hint': 'Currently installed app version.',
'settings.whatsnew.label': "What's new",
'settings.whatsnew.hint': 'See the latest release notes.',
'settings.whatsnew.btn': 'Open',
@@ -548,6 +582,7 @@ export const en: Dict = {
'achievements.title': 'Achievements',
'achievements.unlocked_of': '{n} of {total}',
'achievements.progress': '{n} to go',
'achievements.announce': 'Achievement unlocked: {title}',
'achievement.reps.desc': '{target} reps total',
'achievement.reps_100.title': 'Century',
'achievement.reps_500.title': 'Five hundred',

View File

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

View File

@@ -42,7 +42,7 @@ function totalDoneReps(
let sum = 0
for (const e of history) {
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
}

View File

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

View File

@@ -30,6 +30,35 @@ function shiftDays(base: Date, dayDelta: number): Date {
* Reps logged on a given local day. Uses `actualReps` if present, otherwise
* 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(
entries: HistoryEntry[],
exercises: Exercise[],
@@ -40,7 +69,7 @@ export function dailyReps(
for (const e of entries) {
if (e.action !== 'done') 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
}
@@ -72,7 +101,7 @@ export function dailyRepsRange(
const k = dayKey(e.ts)
const bucket = buckets.get(k)
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
}

View File

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

View File

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

View File

@@ -22,12 +22,16 @@ const GAME_NAMES: Record<GameId, string> = {
type Draft = Omit<Challenge, 'id'>
// exerciseName умышленно пустой — пусть пользователь сам выберет что
// делать. Раньше дефолт был «Приседания» — в EN-локали это выглядело как
// баг (русский текст в английском UI). Required-валидация всё равно
// требует непустого значения перед save.
const EMPTY_DRAFT: Draft = {
name: '',
gameId: 'dota2',
stat: 'deaths',
multiplier: 3,
exerciseName: 'Приседания',
exerciseName: '',
icon: 'Activity',
enabled: true
}
@@ -135,8 +139,13 @@ export default function ChallengesPage(): JSX.Element {
</>
) : (
<Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
{t('challenges.empty')}
<div className="px-5 py-12 flex flex-col items-center text-center">
<div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
<Gamepad2 size={24} strokeWidth={2.4} />
</div>
<div className="text-text/65 text-[15px] font-medium max-w-xs leading-snug">
{t('challenges.empty')}
</div>
</div>
</Card>
)}

View File

@@ -1,22 +1,37 @@
import { useEffect, useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus, Pause, Play, Flame, Activity, TrendingUp } from 'lucide-react'
import {
Plus,
Pause,
Play,
Flame,
Activity,
TrendingUp,
Video
} from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor'
import { HistoryHeatmap } from '../components/HistoryHeatmap'
import { AchievementsCard } from '../components/AchievementsCard'
import { Button } from '../components/ui/Button'
import type { Exercise, HistoryEntry } from '@shared/types'
import { ConfirmModal } from '../components/ui/ConfirmModal'
import type { Exercise, GameStatus, HistoryEntry } from '@shared/types'
import { formatCountdown } from '../lib/format'
import { useT } from '../i18n'
import { currentStreak, dailyReps, todayKey } from '../lib/history'
import {
currentStreak,
dailyReps,
repsDoneTodayForExercise,
todayKey
} from '../lib/history'
export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state)
const ticks = useAppStore((s) => s.ticks)
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Exercise | null>(null)
const [deleting, setDeleting] = useState<Exercise | null>(null)
const { t, lang } = useT()
// Memoise the exercises array reference so downstream useMemos don't fire
@@ -24,16 +39,53 @@ export default function Dashboard(): JSX.Element {
// the parent re-renders even when nothing changed.
const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises])
const settings = state?.settings
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
// Local history mirror; reloaded only when exercises change (not on every
// tick or settings tweak — those don't affect history). When ticks/settings
// change we don't re-fetch.
// Игры: запрашиваем реальный статус (integrationActive + launchOption
// applied), а не просто `state.gamesEnabled`. Без этого badge показывал
// 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[]>([])
useEffect(() => {
void window.api.getHistory().then(setHistory)
const refetch = (): void => {
void window.api.getHistory().then(setHistory)
}
refetch()
return window.api.onHistoryChanged(refetch)
}, [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(
() => dailyReps(history, exercises, todayKey()),
[history, exercises]
@@ -158,23 +210,29 @@ export default function Dashboard(): JSX.Element {
icon={<Activity size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone={gamesEnabled ? 'success' : 'muted'}
tone={
gamesLive ? 'success' : gamesEnabledButNotLive ? 'warning' : 'muted'
}
label={t('dashboard.stat.tracking')}
value={
gamesEnabled
gamesLive
? t('dashboard.stat.tracking.on')
: t('dashboard.stat.tracking.off')
: gamesEnabledButNotLive
? t('dashboard.stat.tracking.pending')
: t('dashboard.stat.tracking.off')
}
subvalue={
gamesEnabled
gamesLive
? 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={
<span
className={[
'w-1.5 h-1.5 rounded-full',
gamesEnabled ? 'bg-white' : 'bg-text/30'
gamesLive ? 'bg-white' : 'bg-text/30'
].join(' ')}
/>
}
@@ -211,6 +269,26 @@ export default function Dashboard(): JSX.Element {
</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>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
<AnimatePresence>
{exercises.map((ex) => (
@@ -218,8 +296,13 @@ export default function Dashboard(): JSX.Element {
key={ex.id}
exercise={ex}
tick={ticks[ex.id]}
doneToday={
ex.dailyGoal !== undefined
? repsDoneTodayForExercise(history, ex)
: undefined
}
onEdit={() => openEdit(ex)}
onDelete={() => window.api.deleteExercise(ex.id)}
onDelete={() => setDeleting(ex)}
onToggle={(v) => window.api.toggleExercise(ex.id, v)}
onMarkDone={() => window.api.markDone(ex.id)}
/>
@@ -247,6 +330,19 @@ export default function Dashboard(): JSX.Element {
onClose={() => setEditorOpen(false)}
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>
)

View File

@@ -93,8 +93,13 @@ export default function Exercises(): JSX.Element {
{exercises.length === 0 && (
<Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
{t('exercises.empty')}
<div className="px-5 py-12 flex flex-col items-center text-center">
<div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
<Plus size={24} strokeWidth={2.5} />
</div>
<div className="text-text/65 text-[15px] font-medium max-w-xs leading-snug">
{t('exercises.empty')}
</div>
</div>
</Card>
)}

View File

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

View File

@@ -4,6 +4,9 @@ import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard'
import { WhatsNewModal } from '../components/WhatsNewModal'
import { ConfirmModal } from '../components/ui/ConfirmModal'
import { Skeleton } from '../components/ui/Skeleton'
import { Spinner } from '../components/ui/Spinner'
import { RELEASE_NOTES } from '@shared/release-notes'
import { useT } from '../i18n'
import type {
@@ -18,7 +21,18 @@ export default function SettingsPage(): JSX.Element {
const settings = useAppStore((s) => s.state?.settings)
const { t } = useT()
if (!settings)
return <div className="p-8 text-text/45">{t('settings.loading')}</div>
return (
<div
className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12 space-y-5"
role="status"
aria-label={t('settings.loading')}
>
<Skeleton className="h-10 w-56" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-24" />
</div>
)
const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p)
@@ -190,6 +204,10 @@ export default function SettingsPage(): JSX.Element {
function AboutCard(): JSX.Element {
const { t } = useT()
const [open, setOpen] = useState(false)
const [version, setVersion] = useState<string>('')
useEffect(() => {
void window.api.getAppVersion().then(setVersion)
}, [])
// Все версии для которых у нас есть заметки, отсортированы desc.
const allVersions = Object.keys(RELEASE_NOTES).sort((a, b) => {
const pa = a.split('.').map(Number)
@@ -199,6 +217,19 @@ function AboutCard(): JSX.Element {
})
return (
<Card>
<Row>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.version.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.version.hint')}
</div>
</div>
<div className="text-[14px] font-mono-num font-semibold text-text/70">
{version ? `v${version}` : '—'}
</div>
</Row>
<Row last>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
@@ -226,8 +257,11 @@ function AboutCard(): JSX.Element {
function DataCard(): JSX.Element {
const { t } = useT()
const [busy, setBusy] = useState(false)
// Какая операция сейчас идёт — чтобы крутить спиннер на нужной кнопке,
// а не на обеих сразу.
const [busy, setBusy] = useState<'export' | 'import' | null>(null)
const [toast, setToast] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
// Простое toast'-сообщение в карточке; через 4 сек чистится.
useEffect(() => {
@@ -237,31 +271,32 @@ function DataCard(): JSX.Element {
}, [toast])
async function onExport(): Promise<void> {
setBusy(true)
setBusy('export')
try {
const r = await window.api.exportState()
if (r.ok && r.path) {
setToast(t('settings.data.export.ok', { path: r.path }))
} else if (!r.ok) {
} else if (!r.ok && !r.canceled) {
// canceled — пользователь сам передумал, тост не нужен.
setToast(t('settings.data.export.err'))
}
} finally {
setBusy(false)
setBusy(null)
}
}
async function onImport(): Promise<void> {
// eslint-disable-next-line no-alert -- modal-confirm для destructive action
if (!window.confirm(t('settings.data.import.confirm'))) return
setBusy(true)
async function performImport(): Promise<void> {
setConfirmOpen(false)
setBusy('import')
try {
const r = await window.api.importState()
if (r.ok) setToast(t('settings.data.import.ok'))
else if ('error' in r && r.error !== undefined) {
else if (!r.canceled) {
// canceled — пользователь не выбрал файл, не показываем error.
setToast(t('settings.data.import.err'))
}
} finally {
setBusy(false)
setBusy(null)
}
}
@@ -278,9 +313,10 @@ function DataCard(): JSX.Element {
</div>
<button
onClick={onExport}
disabled={busy}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
disabled={busy !== null}
className="inline-flex items-center gap-2 h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
>
{busy === 'export' && <Spinner size={14} />}
{t('settings.data.export.btn')}
</button>
</Row>
@@ -294,10 +330,11 @@ function DataCard(): JSX.Element {
</div>
</div>
<button
onClick={onImport}
disabled={busy}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
onClick={() => setConfirmOpen(true)}
disabled={busy !== null}
className="inline-flex items-center gap-2 h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
>
{busy === 'import' && <Spinner size={14} />}
{t('settings.data.import.btn')}
</button>
</Row>
@@ -306,6 +343,15 @@ function DataCard(): JSX.Element {
{toast}
</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>
)
}

View File

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

View File

@@ -66,5 +66,17 @@ export const IPC = {
evtAccentChanged: 'evt:accentChanged',
evtGamesChanged: 'evt:gamesChanged',
evtUpdaterStatus: 'evt:updaterStatus',
evtMaximizeChanged: 'evt:maximizeChanged'
evtMaximizeChanged: 'evt:maximizeChanged',
evtMeetingChanged: 'evt:meetingChanged',
/**
* Шлётся когда история мутирует (markDone / snooze / skip /
* markChallengeDone / clearHistory / import). Renderer'у достаточно
* перезапросить getHistory. Раньше Dashboard переключал history по
* `exercises` ref'у — но markDone мутирует Exercise in place, ref не
* меняется, и heatmap стояла. Этот event — единый сигнал что надо
* перетянуть.
*/
evtHistoryChanged: 'evt:historyChanged',
getMeetingActive: 'system:meetingActive'
} as const

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 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': {
ru: [
{

View File

@@ -111,13 +111,34 @@ export type PersistedState = AppState & {
export type HistoryAction = 'done' | 'skip' | 'snooze'
/**
* Источник записи: обычное напоминание (от scheduler'а) или матч (челлендж).
* Используется для UI («подтянулся в матче» vs «по таймеру») и аналитики.
*/
export type HistorySource = 'reminder' | 'match'
export type HistoryEntry = {
/** ms epoch */
ts: number
/**
* Для обычных напоминаний — Exercise.id. Для challenge'ей — `challenge:<id>`
* (синтетический ключ; renderer'у не нужно искать exercise по нему).
*/
exerciseId: string
action: HistoryAction
/** When user did less than planned. Only meaningful for `done`. */
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 = {