46 Commits
v0.5.0 ... 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
AnRil
77007636df chore(release): v0.5.6 2026-05-22 14:00:34 +07:00
AnRil
433493773d docs(v0.5.6): CHANGELOG + badge 2026-05-22 14:00:28 +07:00
AnRil
5a9ec04ba8 feat(whatsnew): экран «Что нового» — автопоказ после апдейта + кнопка в Settings
- src/shared/release-notes.ts — статический реестр заметок per-version
  (RU + EN), с тегами new/fix/security/perf для tint'а иконок.
- Settings.lastSeenVersion — версия, для которой пользователь видел
  модалку. Валидатор регэксом /^\d+\.\d+\.\d+(-[\w.]+)?$/.
- IPC.getAppVersion → app.getVersion() для renderer'а.
- WhatsNewModal — список пунктов с цветовыми иконками. Footer-кнопка
  «Понятно» / «Got it».
- App.tsx: после hydrate смотрит lastSeenVersion → current. Если
  расходятся и есть пропущенные заметки → автопоказ. На первой
  записи (lastSeenVersion === undefined) — тихо записываем, без
  модалки, чтобы не бить нового пользователя CHANGELOG'ом.
- Settings → раздел «О приложении» → кнопка «Открыть» показывает
  модалку с заметками всех релизов.
2026-05-22 13:59:29 +07:00
AnRil
a0b89ddf71 feat(#2): адаптивный шедулер — сдвигает напоминания на «хорошие» часы из истории 2026-05-22 13:52:38 +07:00
AnRil
81481f2131 feat(#5): авто-пауза напоминаний во время ВКС (Zoom/Teams/Discord/Webex) 2026-05-22 13:48:42 +07:00
AnRil
a6ae931461 feat(#12): дневная цель — soft cap reps/день, после которого упражнение умолкает 2026-05-22 13:45:37 +07:00
AnRil
9e59be9cba feat(#10): достижения — milestones по reps/streaks с прогрессом 2026-05-22 13:41:51 +07:00
AnRil
68998607e8 feat(#7): категории напоминаний (exercise/hydration/eyes/posture) 2026-05-22 13:39:40 +07:00
AnRil
50c56fec79 feat(#8): TTS-голосовые подсказки в окне напоминания (opt-in) 2026-05-22 13:36:29 +07:00
AnRil
72e54c579d feat(#9): export/import состояния — backup в JSON и восстановление 2026-05-22 13:33:38 +07:00
AnRil
fd62177375 chore(release): v0.5.5 2026-05-22 02:01:15 +07:00
AnRil
0a753ad4c7 docs(v0.5.5): CHANGELOG + badge 2026-05-22 02:01:09 +07:00
AnRil
34fb03b265 chore: sprint D — sandbox, self-hosted fonts, logger с ротацией
#6  sandbox: true на обоих BrowserWindow (раньше false). Preload
    использует только contextBridge + ipcRenderer (оба sandbox-safe),
    никаких Node-built-ins. OS-уровневый sandbox изолирует renderer
    от GPU/IPC процессов; даже RCE в зависимости renderer'а не
    получит Node-доступа через preload.

#17 self-host шрифтов через @fontsource/* пакеты. Раньше тянулись
    с fonts.googleapis.com — внешняя CSP-зависимость + отсутствие
    интернета = шрифты не загружались. Теперь .woff/.woff2 в bundle
    (22 файла × 15-30KB = ~500KB).
    Подкрутили CSP: убрали https://fonts.* origins, добавили
    connect-src 'self', base-uri 'self', frame-ancestors 'none'.

#22 src/main/logger.ts — структурный лог с уровнями
    (debug/info/warn/error) и ротацией. Пишет в
    %APPDATA%/Exercise Reminder/logs/latest.log (≤1MB) и
    дублирует в console. При 1MB latest.log → prev.log
    (предыдущий prev.log удаляется). LAUDE_DEBUG=1 включает
    debug-уровень.

    Подключён в hot paths: store (corrupt/atomic write fails),
    updater (silent check errors), gsi-server (bad requests,
    handler throws), games/registry (GSI start, reconcile, match_end
    summary), games/dota2 (rejected token, POST_GAME detection).

    Особенно полезно для диагностики «челленджи не срабатывают»:
    лог покажет (а) пришёл ли вообще GSI payload (token verify),
    (б) детектировался ли POST_GAME, (в) сколько challenges были
    enabled и которые из них дали 0 reps.

    Logger — единственный файл с `eslint-disable no-console` (он
    намеренно дублирует в stderr).
2026-05-22 01:24:30 +07:00
AnRil
e7ccca98e7 perf: sprint C — отделить history от IPC state-broadcast
#9  AppState больше не содержит `history` (вынесено в PersistedState
    — internal store-shape). `broadcastState()` и IPC.getState шлют
    через IPC только exercises/settings/challenges/gamesEnabled.

    Раньше каждый markDone/snooze/toggle вызывал broadcastState() →
    весь state, включая до 10k history-записей (~500KB JSON), летел
    через IPC к каждому BrowserWindow и парсился в renderer'е. На
    долгом горизонте использования становилось заметным лагом UI.

    Renderer и раньше историю из state не читал (Dashboard вызывает
    IPC.getHistory отдельно), так что это чистый perf-win без
    behavioural change. Store-internal mutations продолжают работать
    с полным PersistedState через `getState()`; renderer-bound IPC
    использует новый `getStateForRenderer()`.

Не сделано из спринта C: zustand setState refactor (#8) — текущая
архитектура работает корректно (zustand bathes), `?? []` fallback'и
в селекторах безопасны. Реальный gain был от #9, который и закрыт.
2026-05-22 01:18:25 +07:00
AnRil
4745f5e091 perf+fix: sprint B — async I/O, before-quit, immutable getState, lucide tree-shake
#2  atomicWrite spin-loop → async setTimeout. Раньше при retry на
    EBUSY/EPERM (антивирус, OneDrive) main process замораживался на
    50/200/800ms × до 3 итераций ≈ секунда залипания UI. Сейчас async
    sleep — event-loop живёт. Сохранён atomicWriteSync для flushNow
    (вызывается из before-quit когда event-loop уже умирает).
    Аналогичный фикс в games/steam-launch-options.ts.
#5  before-quit теперь дожидается stopGamesRegistry через
    e.preventDefault() + app.exit(0). Раньше GSI HTTP server не успевал
    closeAllConnections до exit, и следующий запуск получал
    EADDRINUSE на port 4701 (TIME_WAIT) — GSI молча не работал.
#10 IPC.getState возвращает поверхностную копию settings вместо мутации
    кэша. Раньше startWithWindows писалось напрямую в state.settings,
    разъезжаясь с persisted-disk-значением до следующего mutation.
#19 lib/icon.tsx: `import * as Lucide` (wildcard, ~500KB в bundle,
    1500+ иконок) → explicit named imports + ICON_MAP. В bundle
    остаются только 18 ICON_CHOICES.
2026-05-22 01:15:31 +07:00
AnRil
a41dce511b chore: sprint A — мелкая полировка
#15 a11y: <html lang> синхронизируется с settings.language через
    ThemeProvider — screen-readers больше не читают EN-текст с
    русским акцентом и наоборот.
#14 dev:simulateMatchEnd channel вынесен в IPC enum
    (IPC.devSimulateMatchEnd) — main/preload не разойдутся в hardcoded
    строках.
#34 ChallengeEditor: multiplier клампится к [0.5, 1000] (max="1000",
    Math.min(1000, ...)). Совпадает с validate.ts — раньше save с 9999
    молча отклонялся IPC, теперь UI не даёт ввести.
#28 package.json: добавлен `test:coverage` script.
2026-05-22 01:13:01 +07:00
AnRil
9378cabfe5 chore(release): v0.5.4 2026-05-19 21:34:13 +07:00
AnRil
c735659567 docs(v0.5.4): CHANGELOG + badges (tests 53 → 135) 2026-05-19 21:34:02 +07:00
AnRil
c5c05ee651 feat(updater): фоновое скачивание + моментальный рестарт
Раньше после «Скачать» renderer ждал promise (`ipcRenderer.invoke`),
пока electron-updater не завершит весь download. Если пользователь
закрывал Settings и уходил на Dashboard — скачивание продолжалось,
но кнопка возвращалась в `busy=true` при следующем открытии.
Сама установка через `quitAndInstall()` без параметров поднимала
NSIS-диалог установщика — ~5-10 сек до запуска новой версии.

Что изменилось:

- IPC `updaterDownload` / `updaterInstall` — fire-and-forget через
  `ipcMain.on` / `ipcRenderer.send`. Renderer триггерит и забывает,
  прогресс приходит через `evtUpdaterStatus`. UI моментально
  переключается в kind:'downloading' и не блокируется ожиданием.
- `autoUpdater.quitAndInstall(true, true)`:
    - isSilent=true: NSIS работает без UI установщика (~1-2 сек
      вместо ~5-10), без чёрного окна на половину экрана.
    - isForceRunAfter=true: гарантия что приложение запустится
      после установки (иначе пользователь нажал «Рестарт» и остался
      без открытого приложения).
- UpdaterCard: убран `busy` для async download — статус сам
  переключается через события. Добавлена подсказка «можно закрыть
  это окно, скачивание продолжится в фоне». Подкручен subtitle на
  downloaded-state: «нажми Рестарт — приложение моментально
  откроется в новой версии».
- i18n: новый ключ `updater.downloading.hint` (RU + EN), обновлён
  `updater.downloaded.subtitle`.

`autoInstallOnAppQuit = true` уже был включён — если пользователь
не нажал «Рестарт» и просто закрыл приложение, установка
произойдёт при следующем закрытии автоматически.
2026-05-19 21:33:00 +07:00
AnRil
36085f225f test: expand coverage 53 → 135 (+82 tests)
Аудит тестов выявил критические пробелы в покрытии. Расширили
существующие файлы и добавили два новых:

Новые файлы:
- src/main/validate.test.ts (59) — security-boundary IPC layer вообще
  не имел тестов. Покрывает NaN/Infinity, range edge cases, тип-
  сабверсии, partial-patch semantics, quietHours regex+dedup.
  Фиксирует контракт «strict для required, lenient для optional
  defaults» (input принимает enabled:'yes' → true, patch строгий).
- src/renderer/src/lib/icon-choices.test.ts (3) — SAMPLE_EXERCISES.icon
  ⊆ ICON_CHOICES (иначе fallback-Activity на первом запуске).

Расширения:
- format.test.ts: NaN/Infinity guard, EN-локаль.
- history.test.ts: DST-safe инвариант (unique keys, monotonic),
  plannedRepsToday, future-dated entries, mixed actions.
- i18n.test.ts: dict parity RU↔EN (с правильным skip для RU-only
  *_few CLDR-категории), regex-injection в var-значениях,
  weekday.short.* parity.

Рефакторинг:
- ICON_CHOICES вынесен в src/renderer/src/lib/icon-choices.ts
  (без JSX) — теперь whitelist импортируется из любого слоя без
  React-зависимости. icon.tsx реэкспортирует для обратной
  совместимости.
2026-05-19 18:15:37 +07:00
AnRil
03ab4eebf5 chore(release): v0.5.3 2026-05-19 17:54:02 +07:00
AnRil
a64f03b3cc docs(v0.5.3): CHANGELOG entry + badge + CLAUDE.md version bump 2026-05-19 17:53:53 +07:00
AnRil
e96ca06587 feat(window): maximize toggle + drag-zone fix + minWidth bump
- Средняя кнопка тайтлбара теперь toggle maximize/restore (была
  hide-to-tray, но иконка Square вводила в заблуждение — выглядит
  как нативная maximize). Double-click по тайтлбару тоже работает.
- Иконка свапается Square ↔ Copy в зависимости от max-state,
  aria-label локализован (titlebar.maximize_aria / restore_aria).
- Новый IPC: toggleMaximizeMain, isMaximizedMain (invoke),
  evtMaximizeChanged (event main → renderer на maximize/unmaximize).
- Фикс drag-зоны: titlebar-nodrag перенесён с обёртки правого
  кластера на сами кнопки. Из-за flex-1 basis-0 пустое место слева
  от кнопок раньше было no-drag — окно нельзя было ухватить рядом.
- minWidth/minHeight окна 900x600 → 1100x700, чтобы Tailwind lg:
  всегда срабатывал (4 hero-stat в один ряд, heatmap без скролла).
- CLAUDE.md: контекст проекта для будущих сессий Claude Code
  (стек, архитектура, команды, релиз, тех. долг, чего не делать).
2026-05-19 17:52:54 +07:00
AnRil
2503b27d42 docs(v0.5.2): update CHANGELOG + README badge 2026-05-19 13:37:59 +07:00
AnRil
ec6735f3f4 chore(release): v0.5.2 2026-05-19 13:27:27 +07:00
AnRil
85897aa7dc fix(a11y+i18n): heatmap/weekdays via dict, Sidebar focus trap, debounce time-picker
Third pass through the audit list. Tests still 53 passing, typecheck and
ESLint clean.

i18n — finish removing hardcoded localised strings from components
- Add 7 weekday short labels (weekday.short.0..6, index = Date.getDay()).
- Settings QuietDaysRow + HistoryHeatmap now pull weekday labels from
  the dict instead of inline ru/en arrays.
- Heatmap title, legend (Less/More), and per-cell rep tooltip are now
  i18n keys; the tooltip uses translateN with proper Russian plurals
  (1 повтор / 2 повтора / 5 повторов).
- New aria labels: sidebar.aria.nav, exercise.aria.toggle.
- HistoryHeatmap no longer takes a `lang` prop — pulls language from
  useT() like every other component.

Heatmap intensity scaling
- Bucket thresholds now percentile-based (p25/p50/p85 over non-zero days)
  rather than a flat ratio against the single max. A 200-rep "catch up"
  day no longer collapses every normal 10-rep day into the lowest bucket.

Sidebar mobile drawer
- Esc closes the drawer.
- Tab/Shift-Tab trap inside the drawer.
- Focus restores to the hamburger button on close.
- Drawer gets role="dialog" + aria-modal="true" + aria-label.
- Backdrop gets aria-hidden so screen readers skip the scrim.

Settings — stop IPC chatter on time picker
- QuietTimesRow mirrors `from`/`to` into local state and only emits an
  updateSettings IPC on blur (or when the local value matches HH:MM and
  differs from the current setting). Was firing ~5 IPCs while the user
  scrubbed time inputs, each rewriting app-state.json.
- QuietDaysRow uses a numeric sort comparator instead of default lexical.

Dashboard polish
- "Until next reminder" hero stat now shows "—" when paused instead of
  continuing to tick down a misleading countdown.

ExerciseCard
- Switch aria-label was t('btn.done') ("Готово") — wrong semantics.
  Now reads "Toggle exercise X" via new i18n key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 13:23:41 +07:00
AnRil
f0dc5b2cc3 feat: a11y + Error Boundary + IPC validation + schema migrations
Second pass through the audit punch-list. ESLint and Prettier now clean
(0 errors, 0 warnings), typecheck clean, 53 tests pass.

ACCESSIBILITY (Modal)
- Full focus trap: Tab/Shift-Tab cycle within the dialog and never
  escape to the underlying page.
- Focus restoration: closing returns focus to the trigger button.
- First focusable child is focused on open (skipping the X button).
- aria-labelledby links the dialog to its <h2> via useId().
- Close button's hardcoded "Закрыть" replaced with i18n key.

ERROR RECOVERY
- Add ErrorBoundary component (class — only way) with localized
  fallback and a "try again" reset button. Stack trace shown only in
  dev. Wrapped around the whole App + a nested boundary around the
  routed pages so a crash in one route doesn't blank the chrome.
- Module-level guard on subscribeToBackend so React 18 StrictMode's
  dev-mode double-mount doesn't subscribe twice.
- Loading placeholder is now blank (was hardcoded Russian "Загрузка…"
  that English users would see during initial hydration).

TRAY i18n
- 5 tray strings now follow the current settings.language. Falls back
  to Russian when the store isn't loaded yet or the lang is unknown.
- refreshMenu() called on settings.language change and on
  pauseAll/resumeAll so the pause label stays in sync with state.

IPC VALIDATION (src/main/validate.ts)
- Hand-rolled validators for every renderer-supplied payload:
  exercise input/patch, challenge input/patch, settings patch, id,
  actualReps, snoozeMinutes. Range-check numeric fields
  (intervalMinutes ∈ [1, 1440], reps ∈ [1, 9999], multiplier ∈ [0,
  1000], snooze ∈ [1, 1440]), cap string lengths at 200, restrict
  enums (theme/lang/notify-mode/stat) to known values, validate
  quietHours.from/to with HH:MM regex and dedup quietHours.days.
- Every ipcMain.handle for mutations now runs the validator first and
  returns null on rejection instead of pushing junk into the store.
  A compromised renderer can no longer corrupt persisted state via
  out-of-range numbers or wrong-type fields.

SCHEMA MIGRATIONS (src/main/store.ts)
- Add __schemaVersion field to persisted state with CURRENT = 1.
- MIGRATIONS map: { 0: (s) => s } as a no-op seed; future structural
  changes (e.g. quietHours shape revision) get a single explicit slot.
- runMigrations() applies migrations in order; coerce() normalises the
  result into a fully-formed AppState. Both first-write and every
  flush() persist the version field.

EXHAUSTIVE-DEPS WARNINGS
- Dashboard: memoise `exercises` so downstream useMemos don't fire on
  every parent render; gate the history fetch on exercises change
  instead of any state change.
- HistoryHeatmap: wrap `weeks` in useMemo so monthLabels' deps are
  stable.

LINT POLISH
- updater.ts: refactor a Prettier-vs-no-extra-semi conflict by
  extracting the cast into a local binding.
- Remove dead import of `Challenge` from ipc.ts (now imported via
  validators).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 23:21:27 +07:00
AnRil
f3367e09de chore+fix: repo hygiene, code-review fixes, audit cleanup
Three independent code reviews + a security audit produced ~200 findings.
This commit lands the high-impact subset. Tests pass (53), typecheck
clean, eslint clean (3 minor exhaustive-deps warnings left).

REPO HYGIENE
- Add .editorconfig, .prettierrc.json, .prettierignore.
- Add ESLint flat config (.eslintrc.cjs) — correctness-focused, no style
  rules (Prettier owns formatting).
- Add `format` / `format:check` / `lint` npm scripts.
- Add CHANGELOG.md (Keep a Changelog format, back-filled to 0.1.x).
- Reformat all source via Prettier so future diffs stay small.

DATA SAFETY (src/main/store.ts)
- Atomic write (tmp + rename) with retry on transient EBUSY/EPERM —
  was non-atomic writeFileSync, vulnerable to truncation on power loss.
- On corrupt JSON, rename to `app-state.json.corrupt-<ts>` instead of
  silently overwriting the user's exercises/history with defaults.
- Validate parsed shape before merging — reject arrays/scalars where
  objects expected; per-field array checks.
- Strip `id` from incoming patches in updateExercise/updateChallenge —
  a runtime caller (IPC) could otherwise smuggle id changes through.
- clearHistory now refuses an unbounded wipe (no beforeTs => no-op);
  callers must pass an explicit boundary.
- unref() the debounce timer so it doesn't keep the event loop alive.

SECURITY (src/main/*)
- gsi-server: hard 256 KB body cap (was unbounded — local OOM vector),
  reject any Origin/Sec-Fetch-Site header (blocks browser CSRF from
  visited pages), require application/json Content-Type, generic 400
  on parse error (no error string echo to client), closeAllConnections
  + async close on stop.
- dota2: validate auth.token from payload with timingSafeEqual against
  the per-install token — was unauthenticated, any local process could
  forge match-end events. Narrow object shape before spread-merge to
  avoid throws on hostile payloads like {player:"x"}. Reset latest /
  prevState after match_end so the next match starts clean.
- ipc: gate `dev:simulateMatchEnd` registration behind `!app.isPackaged`
  so it does not exist in shipped builds.
- preload: gate the matching `simulateMatchEnd` export behind
  `import.meta.env.MODE !== 'production'` so the bundler dead-code-
  eliminates it from the production preload bundle.
- windows: shell.openExternal allowlist (http/https/mailto only) — was
  forwarding any URL, including file:/javascript:/custom URI handlers
  (some Windows handlers have been RCE vectors). will-navigate blocks
  navigation to anywhere except file:// or the dev URL.

CORRECTNESS (src/main/* + src/shared/*)
- shared/types.ts isQuietAt: fix wrap-around + day-of-week filter.
  With from=22:00 to=07:00 days=[Mon..Fri], the window started THE
  PREVIOUS DAY when we're in the AM half — old code checked today's
  day-of-week and got the wrong answer Sat 02:00 and Mon 01:00. Now
  the filter is evaluated against the window's START day. Also reject
  malformed HH:MM strings instead of producing NaN.
- scheduler: call broadcastState() after firing exercises so the
  renderer's Dashboard/Exercises pages don't show stale nextFireAt
  until the next state-changing IPC. Guard powerMonitor listeners
  against double-registration on dev hot-reload.
- dota2: fix `launchOptionStatus = steamRunning ? 'queued' : 'queued'`
  tautology — both branches now correctly read 'queued'.
- steam-launch-options: replace `require('node:fs')` inside atomicWrite
  with the top-level import; retry on transient EBUSY/EPERM.

CORRECTNESS (src/renderer/*)
- lib/history.ts: replace `today.getTime() - i * MS_DAY` arithmetic
  with `setDate(date - i)` calendar arithmetic in dailyRepsRange and
  currentStreak — DST transitions shift epoch math by ±1h and cause
  dayKey() to emit duplicate or missing days at the boundary.
- lib/icon.tsx: restrict name lookup to ICON_CHOICES set — an arbitrary
  string from a corrupted state file could otherwise resolve to
  unrelated Lucide exports and crash the renderer.
- lib/format.ts: guard formatCountdown against NaN/Infinity.
- i18n/index.ts: replace regex-based interpolation with split/join so
  variable values containing regex metacharacters interpolate
  literally; warn in dev on missing keys; clamp pluralRu(-N) via abs.
- ReminderApp: keyboard shortcuts moved INTO ExerciseReminder so Enter
  respects the stepper's `adjusted` flag (was always passing planned
  reps). Stepper capped at 5× planned. Don't hijack Space when a
  button is focused. `key={exercise.id+nextFireAt}` forces a fresh
  component for back-to-back reminders so stepper state resets. Match
  summary view gets Esc-to-close. Functional setMode in onMarkDone
  avoids races against stale `mode.done`.
- UpdaterCard: guard against NaN/Infinity in download-progress events
  (electron-updater fires early events with undefined fields).
- Games: gate DevPanel behind `import.meta.env.DEV` in addition to the
  main-side IPC gate, and narrow the `simulateMatchEnd` access.
- Add aria-labels for the +/- stepper buttons (i18n keys added).

TESTS
- +2 quiet-hours tests covering wrap-around + day-filter combo and
  malformed HH:MM fallback. Total 53 passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 23:04:49 +07:00
AnRil
d6f94ee1c9 docs+chore: retry upload on TLS/504 + refresh README/RELEASING
Upload script:
- Retry curl on transient network failures (504, schannel TLS abrupt
  close): up to 4 retries with 15s/45s/2m/5m backoff. Before each retry,
  list the release assets server-side — Gitea sometimes commits the
  body but times out the response, so the file may already be there at
  the expected size (skip retry). If present at wrong size (partial),
  delete before re-uploading. ASCII-only (PS5.1 reads files in CP1251
  without BOM).

Docs:
- README: bump release/test badges to v0.5.1 / 51 tests; mention silent
  retry in the auto-update feature line.
- RELEASING: rewrite around the new update-channel architecture, bridge
  tags, and dropped Gitea Actions workflows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:37:33 +07:00
AnRil
6160ece8d4 fix(release): retry uploads with backoff + drop Gitea workflows
Gitea/nginx intermittently returns 504 on large multipart uploads even
when curl successfully streamed the body. Add up to 4 retries with
exponential backoff (15s/45s/2m/5m). Before each retry, check whether
the asset is actually present server-side at the expected size — Gitea
sometimes accepts the body but times out the response, so the file is
already there.

Also drop .gitea/workflows/* — we use release.ps1 locally and Gitea
Actions runners are not configured, so every push was leaving queued/
failed workflow runs in the Actions tab.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 21:51:41 +07:00
AnRil
3f038e59e8 chore(release): v0.5.1
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
Release / Build installer + publish release (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
2026-05-18 15:25:17 +07:00
AnRil
33e237948e fix(release): write package.json as UTF-8 without BOM
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
PS5.1 Get-Content -Raw without -Encoding utf8 reads in CP1251, mangling
non-ASCII like em-dash. Set-Content -Encoding utf8 writes a BOM that
breaks PostCSS / electron-builder reads of package.json.

Use .NET ReadAllText/WriteAllText with UTF8Encoding(false) to guarantee
roundtrip-safe UTF-8 without BOM.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 15:25:04 +07:00
AnRil
f861af5db1 feat(updater): fixed-URL auto-update channel + silent retries
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
The auto-update system used a per-version publish URL
(releases/download/v${version}), so each installed build only ever
checked its own release page for new versions. To deliver an update we
had to manually copy the new manifest into every old release — easy to
forget, and any half-uploaded state showed users red "check failed"
banners.

Architectural fix:

- New rolling 'update-channel' Gitea release. publish.url is now a
  fixed path (.../releases/download/update-channel) that never moves.
- release.ps1 uploads each new build to three places:
    1. vX.Y.Z          (historical archive + changelog)
    2. update-channel  (what every client polls)
    3. -BridgeTags     (transition: also fill in old releases so users
                       still on those versions can find the new build)
- upload-release-assets.ps1 gains -AssetVersion to upload version-X.Y.Z
  artifacts into a non-version tag (channel/bridge).

Resilience fixes for the updater itself:

- Hourly checks and the boot check now run in SILENT mode: network
  errors don't promote to a red error state, they're logged and
  retried on the next tick. Only user-initiated "Check now" surfaces
  errors. This prevents the cascade of "Ошибка проверки" cards on
  flaky networks or partial uploads.
- Boot check retries up to 3 times (30s/2m/5m backoff) before giving
  up until the hourly tick.
- Track lastCheckedAt; "Up to date" subtitle now shows "checked Nm ago".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 15:23:41 +07:00
89 changed files with 10489 additions and 962 deletions

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.{md,markdown}]
trim_trailing_whitespace = false
[*.{ps1,psm1,psd1}]
end_of_line = crlf
[Makefile]
indent_style = tab

63
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,63 @@
/**
* ESLint focuses on correctness, NOT style — Prettier owns formatting.
* Stylistic rules that fight Prettier are off.
*/
module.exports = {
root: true,
env: { browser: true, node: true, es2022: true },
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
ecmaFeatures: { jsx: true }
},
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
settings: { react: { version: 'detect' } },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended'
],
rules: {
// React 17+ JSX transform — no need to import React in scope.
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off', // we use TS
// Hooks correctness — high signal.
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// TS — pragmatic, not strict.
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
],
'@typescript-eslint/no-non-null-assertion': 'off',
// Vanilla — common bugs.
'no-console': ['warn', { allow: ['warn', 'error', 'info'] }],
'no-debugger': 'error',
'prefer-const': 'warn',
eqeqeq: ['error', 'always', { null: 'ignore' }]
},
ignorePatterns: [
'node_modules',
'out',
'release',
'dist',
'*.tsbuildinfo',
'src/preload/index.d.ts'
],
overrides: [
{
files: ['**/*.test.ts', '**/*.test.tsx'],
env: { node: true }
},
{
files: ['*.config.js', '*.config.ts', 'electron.vite.config.ts'],
rules: { '@typescript-eslint/no-var-requires': 'off' }
}
]
}

View File

@@ -1,65 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
quality:
name: Typecheck + Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Typecheck (main + preload + shared)
run: npm run typecheck:node
- name: Typecheck (renderer)
run: npm run typecheck:web
- name: Run unit tests
run: npm run test:run
build:
name: Build (Windows)
runs-on: windows-latest
needs: quality
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build production bundle (no installer)
run: npm run build
- name: Smoke-test unpacked build
run: npm run dist:dir
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload unpacked artifact
uses: actions/upload-artifact@v4
with:
name: exercise-reminder-unpacked
path: release/win-unpacked/
retention-days: 7

View File

@@ -1,75 +0,0 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
jobs:
release:
name: Build installer + publish release
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Verify version matches tag
shell: pwsh
run: |
$tag = "${{ gitea.ref_name }}"
$expected = $tag.TrimStart('v')
$actual = (Get-Content package.json | ConvertFrom-Json).version
if ($expected -ne $actual) {
Write-Error "Tag $tag does not match package.json version $actual"
exit 1
}
Write-Host "Version match: $actual"
- name: Typecheck
run: npm run typecheck
- name: Run unit tests
run: npm run test:run
- name: Build NSIS installer
run: npm run dist
env:
# electron-builder uses this when --publish flag is set; we publish
# to a Gitea release manually below to avoid hard-coupling.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate release notes from commits
id: notes
shell: pwsh
run: |
$tag = "${{ gitea.ref_name }}"
$prev = git describe --tags --abbrev=0 "$tag^" 2>$null
if ($prev) {
$log = git log --pretty=format:"- %s" "$prev..$tag"
} else {
$log = git log --pretty=format:"- %s" "$tag"
}
$notes = "### Изменения`n`n$log`n`n---`n`nУстановщик ниже — запустить и следовать мастеру. Если приложение уже стояло — обновится поверх с сохранением настроек."
$encoded = $notes -replace "`r?`n", "%0A"
"notes=$encoded" | Out-File -FilePath $env:GITEA_OUTPUT -Append
- name: Create Gitea release with artifacts
uses: akkuman/gitea-release-action@v1
with:
server_url: ${{ gitea.server_url }}
token: ${{ secrets.GITEA_TOKEN }}
name: 'Exercise Reminder ${{ gitea.ref_name }}'
body: ${{ steps.notes.outputs.notes }}
files: |
release/Exercise-Reminder-Setup-*.exe
release/Exercise-Reminder-Setup-*.exe.blockmap
release/latest.yml

8
.prettierignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
out
release
dist
package-lock.json
*.tsbuildinfo
resources/**/*.ico
resources/**/*.png

18
.prettierrc.json Normal file
View File

@@ -0,0 +1,18 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 80,
"tabWidth": 2,
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf",
"overrides": [
{
"files": ["*.md", "*.markdown"],
"options": {
"proseWrap": "preserve"
}
}
]
}

460
CHANGELOG.md Normal file
View File

@@ -0,0 +1,460 @@
# Changelog
Все заметные изменения проекта документируются здесь.
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект следует [Semantic Versioning](https://semver.org/lang/ru/).
## [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 новыми фичами + экран «Что нового», который покажется
автоматически после установки этой версии.
### Added
- **Категории напоминаний** (#7) — кроме упражнений теперь
hydration / eye-rest (20-20-20) / posture. Каждая со своим CTA в
окне напоминания. В SAMPLE добавлены примеры.
- **TTS-голосовые подсказки** (#8) — диктор произносит название
упражнения и количество. Opt-in в Settings → «Голосовая подсказка».
Web Speech API: подбор голоса под язык (ru-RU / en-US).
- **Достижения** (#10) — milestones по total reps (100/500/1000/5k/10k),
streaks (3/7/14/30/100 дней), first_day, today_quad. На Dashboard
карточка с unlocked + 2 ближайших по прогрессу. Прогресс-бар до
следующего достижения.
- **Дневная цель** (#12) — soft cap reps/день в exercise editor.
Когда total reps за сегодня (с actualReps) ≥ dailyGoal → scheduler
переносит fire на завтра. История = source of truth.
- **Авто-пауза на ВКС** (#5) — сканирует процессы tasklist'ом раз в
30с: Zoom/Teams (старый+new)/Discord/Webex/Slack/Skype/Meet/Whereby/
GoToMeeting. Если запущен — fires не выполняются.
- **Адаптивный шедулер** (#2) — opt-in флаг в exercise editor.
Heuristic-модель строит hour-of-day success rate по 30 дням истории
(≥10 событий обязательно). При попадании fire в «плохой» час
(success ≤ 30%) сдвигает на ближайший «хороший» (≥50%), в пределах
4 часов.
- **Export / Import** (#9) — Settings → Data. Native save/open
dialogs Electron. JSON-snapshot всего persisted-state (включая
историю). Import = replace, не merge — UI просит подтверждение.
- **Экран «Что нового»** — модалка с заметками релизов. Показывается
автоматически когда `lastSeenVersion` ≠ текущей версии (после
обновления). Доступна также из Settings → «О приложении».
Реестр заметок в `src/shared/release-notes.ts`, версионируется
per-app.
### Changed
- Settings.lastSeenVersion (optional) — отслеживает что пользователь
видел.
- IPC.getAppVersion → app.getVersion() для renderer.
- DEFAULT_SETTINGS: добавлены `voicePromptsEnabled: false`,
`meetingAutoPause: true`.
- Exercise type расширен: `category?`, `dailyGoal?`, `adaptive?`.
Все обратно совместимые (optional).
### Performance
- Scheduler запрашивает history только если есть упражнения с
dailyGoal или adaptive — иначе экономит IPC.
## [0.5.5] — 2026-05-22
Большой sweep по ревизии: 4 спринта правок (≈14 пунктов), все 135 тестов
зелёные. Главное — UI больше не залипает при retry'ях I/O, GSI порт не
зависает в TIME_WAIT после выхода, sandbox включён, шрифты self-hosted.
### Security
- **`sandbox: true`** на обоих BrowserWindow. Preload использует только
contextBridge + ipcRenderer (sandbox-safe), никаких Node-built-ins.
OS-уровневый sandbox изолирует renderer на уровне процессов — даже
RCE в зависимости рендерера не получит Node-доступа через preload.
- **CSP ужесточён.** Убраны `https://fonts.googleapis.com` и
`https://fonts.gstatic.com` origins (шрифты теперь self-hosted),
добавлены `connect-src 'self'`, `base-uri 'self'`,
`frame-ancestors 'none'`.
### Added
- **Self-hosted шрифты.** Plus Jakarta Sans, Bricolage Grotesque,
JetBrains Mono подключены через `@fontsource/*` пакеты — в bundle
лежат локально, без интернета шрифты работают, CSP без внешних
origins. +22 .woff/.woff2 (~500KB) в installer.
- **`src/main/logger.ts`** — структурный logger с уровнями
(debug/info/warn/error) и ротацией. Пишет в
`%APPDATA%/Exercise Reminder/logs/latest.log` (≤1MB) и дублирует
в console. При 1MB ротируется в `prev.log`. `LAUDE_DEBUG=1`
включает debug-уровень. Подключён в hot paths: store, updater,
GSI server, registry, dota2 provider — особенно полезно для
диагностики «челленджи не срабатывают» (видно token verify,
POST_GAME detection, фильтрацию challenges).
- `<html lang>` синхронизируется с `settings.language` через
ThemeProvider — screen readers корректно произносят язык.
- `dev:simulateMatchEnd` channel вынесен в IPC enum
(`IPC.devSimulateMatchEnd`).
- `test:coverage` npm script.
### Changed
- **`broadcastState` больше не шлёт `history`** через IPC. Раньше
каждый markDone/snooze отправлял весь state включая до 10k
history-записей (~500KB JSON) к каждому BrowserWindow. Теперь
`AppState` (renderer-facing) без `history`, а `PersistedState`
(internal) с историей. Renderer и так дёргал `getHistory()`
отдельно, поведение не изменилось — только perf.
- **`lib/icon.tsx`**: `import * as Lucide` (wildcard, ~500KB всех
1500+ иконок в bundle) → explicit named imports + ICON_MAP.
В bundle только 18 ICON_CHOICES.
- **ChallengeEditor**: multiplier клампится в UI до [0.5, 1000]
(совпадает с validate.ts). Раньше save с 9999 молча отклонялся
IPC-валидатором.
### Fixed
- **`atomicWrite` spin-loop → async setTimeout.** Раньше при retry
на EBUSY/EPERM (антивирус, OneDrive) main process замораживался
на 50/200/800ms × до 3 итераций ≈ секунда залипания UI. Сейчас
async sleep — event-loop живёт. Аналогичный фикс в
`games/steam-launch-options.ts`. Сохранён sync-вариант для
`flushNow` в `before-quit` (там event-loop уже не работает).
- **`before-quit` дожидается `stopGamesRegistry`** через
`e.preventDefault()` + `app.exit(0)`. Раньше GSI HTTP server
не успевал `closeAllConnections` до exit, и следующий запуск
получал EADDRINUSE на порту 4701 (TIME_WAIT) — GSI молча не
работал.
- **IPC `getState` не мутирует кэш.** Раньше `state.settings.startWithWindows`
перезаписывалось напрямую, разъезжаясь с persisted-disk-значением
до следующего mutation. Сейчас возвращается поверхностная копия.
## [0.5.4] — 2026-05-19
Обновление приложения теперь по-настоящему фоновое + почти моментальный
рестарт в новую версию.
### Changed
- **Скачивание апдейта — фоновое.** Раньше клик «Скачать» блокировал
кнопку (`busy=true`) до конца download'а (минуты на медленной сети).
Теперь IPC `updaterDownload` — fire-and-forget, прогресс приходит
через события. Пользователь сразу может уйти на Dashboard и
продолжать упражнения, апдейт качается в фоне.
- **«Рестарт» — почти моментальный.** `quitAndInstall(true, true)`:
isSilent=true — NSIS без UI установщика (~1-2 сек вместо ~5-10),
isForceRunAfter=true — гарантия что приложение откроется после.
Раньше показывался диалог установщика с прогрессом, теперь —
только мгновение между закрытием и появлением новой версии.
- Подсказка на экране скачивания: «можно закрыть это окно, продолжится
в фоне». На downloaded-экране: «нажми Рестарт — приложение
моментально откроется в новой версии».
## [0.5.3] — 2026-05-19
Полировка кастомного тайтлбара и размера окна.
### Added
- **Maximize/Restore.** Средняя кнопка тайтлбара (иконка квадрата)
раньше была «спрятать в трей» — выглядела как нативная Windows
maximize и сбивала с толку. Теперь это настоящий toggle на
весь экран: иконка свапается `Square` ↔ `Copy` в зависимости
от состояния, aria-label локализован.
- **Double-click по тайтлбару** тоже toggleMaximize — стандартный
Windows-жест.
- **CLAUDE.md** в корне — контекст проекта для будущих сессий
Claude Code (стек, архитектура, команды, тех. долг).
### Fixed
- **Drag-зона тайтлбара.** Окно не двигалось, если хватать его
рядом с кнопками свернуть/закрыть. Класс `titlebar-nodrag` стоял
на обёртке кластера с `flex-1 basis-0`, поэтому пустое место
слева от иконок тоже было no-drag. Перенесли `no-drag` на сами
кнопки — теперь тащить можно отовсюду, кроме самих квадратиков.
### Changed
- **Минимальный размер окна** 900×600 → 1100×700. Гарантирует
срабатывание Tailwind `lg:` (4 hero-stat в один ряд, heatmap
и сетка упражнений помещаются без горизонтального скролла).
## [0.5.2] — 2026-05-19
Большая внутренняя итерация: тройной независимый аудит (~220 находок),
закрыты топ-приоритеты. Тестов 53, ESLint и Prettier чистые, typecheck OK.
### Added
- **Prettier + ESLint + EditorConfig.** Конфиги, скрипты
`npm run format` / `format:check` / `lint`, CI-готовые правила. Вся
`src/` единообразно отформатирована.
- **Error Boundary** на двух уровнях: вокруг всего App и вокруг
роутов. Крах одной страницы (например, malformed history в
HistoryHeatmap) больше не блэнкит окно — показывается локализованный
fallback с кнопкой «Попробовать снова». Stack trace только в dev.
- **IPC validation layer** (`src/main/validate.ts`) — hand-rolled
схемы для всех renderer-supplied payload (intervalMinutes ∈ [1,1440],
reps ∈ [1,9999], multiplier ∈ [0,1000], string-cap 200 chars,
enum-валидация для theme/lang/notify-mode/stat, regex для HH:MM,
дедупликация quietHours.days). Compromised renderer больше не может
засунуть `reps: NaN` или `intervalMinutes: -1` в стор.
- **Schema migrations framework.** `__schemaVersion` в persisted-state,
`MIGRATIONS` map для будущих структурных правок.
- **Modal focus trap + focus restore + aria-labelledby.** Tab/Shift-Tab
больше не вываливаются на нижний слой; на закрытии фокус
возвращается на триггер.
- **Sidebar mobile drawer:** Esc закрывает, focus trap внутри, focus
restore на гамбургер, `role="dialog"` + `aria-modal`.
- **Tray menu i18n** — пункты меню следуют `settings.language`.
- **Bilingual heatmap.** Title, легенда, weekday-лейблы и tooltip
с плюрализацией (1 повтор / 2 повтора / 5 повторов) — всё через
i18n. 7 новых ключей `weekday.short.*`.
- CHANGELOG.md по формату Keep a Changelog.
### Fixed
- **Critical: данные больше не теряются на corrupt JSON.** Раньше
`catch → makeInitial()` молча затирал упражнения/историю. Теперь
файл уезжает в `app-state.json.corrupt-<timestamp>`.
- **Atomic write через `.tmp` + rename + retry** на EBUSY/EPERM
(антивирус, OneDrive). Раньше обрыв питания мог дать truncate.
- **HIGH security: GSI server теперь верифицирует auth.token**
через `timingSafeEqual` против per-install токена. Раньше
эндпоинт был полностью неаутентифицирован — любой локальный
процесс мог подделать match-end.
- **HIGH security: `shell.openExternal` allowlist** —
только `http/https/mailto`. Раньше `file:`/`javascript:`/`steam:`
уходили в OS handler.
- **HIGH security: dev IPC `simulateMatchEnd`** убран из production
билдов (gate на `!app.isPackaged` + `import.meta.env.MODE`).
- **HIGH security: GSI server reject `Origin`/`Sec-Fetch-Site`** —
блокирует CSRF от browser-вкладок. Body cap 256 KB (OOM-вектор
закрыт). Require `application/json`. Generic 400 без error-echo.
- **`isQuietAt` wrap-around + day filter.** С `22:00 → 07:00,
days=[Mon..Fri]` теперь правильно проверяется день *начала* окна
(старт Fri 22:00 → активно ночью Sat 02:00).
- **DST drift в `history.ts`.** Календарная арифметика (`setDate`)
вместо ms-арифметики — на границе DST дни больше не дублируются.
- **Scheduler:** `broadcastState()` после fire, защита от
двойной регистрации `powerMonitor` listeners.
- **Settings IPC chatter.** QuietTimesRow держит локальное состояние,
IPC летит только на `onBlur`. Раньше скрабинг времени давал ~5
IPC, каждый переписывал `app-state.json`.
- **Dashboard** «До следующего» показывает `` при паузе вместо
обманчиво тикающего таймера.
- **HistoryHeatmap** percentile-bucketing (p25/p50/p85) вместо
относительной шкалы — outlier-день больше не схлопывает все
нормальные дни в самый слабый бакет.
- **ReminderApp:** Enter теперь корректно передаёт adjusted reps
(раньше всегда planned). `key={exercise.id+nextFireAt}` сбрасывает
степпер на новом fire. Степпер capped at 5× planned. Space не
работает когда фокус на кнопке. Esc закрывает MatchSummary.
- **`i18n.translate`** — split/join вместо regex (var-значения с
регулярными метасимволами теперь интерполируются буквально).
- **`icon.tsx`** lookup сужен до `ICON_CHOICES` — произвольное имя
больше не зарезолвится в `Lucide.default`.
- **UpdaterCard NaN guard** на download-progress (electron-updater
даёт undefined в ранних событиях).
- **`format.ts`** guard от NaN/Infinity в `formatCountdown`.
- **`updateExercise`/`updateChallenge`** стрипают `id` из patch —
рендер не может перезаписать identity.
- **clearHistory(undefined)** теперь no-op (нужен явный boundary).
### Removed
- `.gitea/workflows/*.yml` — без runners оставляли queued runs.
Релизим через `release.ps1`. has_actions на репо выключен.
## [0.5.1] — 2026-05-18
### Fixed
- **Auto-update архитектурно переписан.** Раньше `publish.url` включал
`${version}` и запекался в каждый билд — установленные копии видели
только свой собственный релиз. Введён фиксированный
`…/releases/download/update-channel`, который никогда не меняется.
- Hourly auto-проверка работает в silent-режиме: транзитные сетевые
ошибки (504, TLS drops) больше не показывают красный баннер
«Ошибка проверки». Только ручной клик «Проверить» поднимает ошибку.
- Boot-check ретраит 3 раза с backoff 30s/2m/5m.
- В `Up to date` показывается «проверено N мин назад».
- `release.ps1` теперь публикует в три-четыре места одной командой:
vX.Y.Z, update-channel, и переданные `-BridgeTags` для миграции
пользователей со старых версий.
- `upload-release-assets.ps1` ретраит curl до 4 раз с backoff на 504 /
TLS-сбрасывание; до ретрая проверяет, не залился ли файл на самом
деле (Gitea часто принимает body, но таймаутит ответ).
- Скрипты — ASCII-only (PS5.1 без BOM падает на em-dash).
### Removed
- `.gitea/workflows/*.yml` — Gitea Actions без настроенных runners
оставляли queued runs в репозитории. Релизим через `release.ps1`.
## [0.5.0] — 2026-05-18
### Added
- **История + стрики.** Каждое выполненное упражнение пишется в
`app-state.json` (cap 10k записей, trim oldest 10% на overflow).
Heatmap-календарь 12 недель на Dashboard, ежедневный счётчик
«сделано сегодня», серия дней подряд (с grace-периодом за вчера).
- **Тихие часы.** Окно времени, в которое напоминания подавляются.
Поддержка wrap-around (22:00 → 08:00) и фильтра по дням недели.
- **Частичное выполнение.** Степпер `/+` в окне напоминания: можно
отметить «сделал 5 из 10», в историю запишется честное число.
- README.md на русском — описание, фичи, установка, dev-команды,
архитектура, стек.
### Changed
- `markDone(id, actualReps?)` принимает фактическое число повторений.
### Tests
- `+18` тестов (5 для тихих часов, 13 для истории/стриков). Всего 51.
## [0.4.0] — 2026-05-17
### Added
- **Английская локализация.** Самописная i18n: плоский словарь
~200 ключей × 2 языка + хук `useT()` + плюрализация (CLDR rules
для RU: one/few/many).
- Селектор языка в Settings, переключение мгновенное.
## [0.3.x] — 2026-05-17
Серия мелких релизов с дизайн-итерациями (Apple iOS / macOS aesthetic):
шрифты Plus Jakarta Sans + Bricolage Grotesque, светлая/тёмная/системная
тема, vibrancy sidebar, iOS-grouped lists, spring-анимации.
## [0.2.0] — 2026-05-16
### Added
- Dota 2 Game State Integration: локальный HTTP-сервер парсит callbacks
от Steam, после Победа/Поражение показывает «причитающиеся»
повторения (например `10 смертей × 3 = 30 приседаний`).
## [0.1.x] — 2026-05-15 .. 2026-05-16
Первые публичные сборки: ядро напоминаний (упражнения, интервалы,
иконки), системный трей, автозапуск с Windows, native-уведомления,
NSIS-инсталлятор, auto-update через electron-updater.
[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

188
CLAUDE.md Normal file
View File

@@ -0,0 +1,188 @@
# CLAUDE.md
Контекст проекта для Claude Code. Читается при старте каждой сессии.
## TL;DR
**Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.8**. Один разработчик (AnRil), один remote — self-hosted Gitea.
## Стек
- **Runtime**: Electron 33 (main + preload + renderer)
- **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 (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)
## Архитектура (важное)
### Процессы
- **main** (`src/main/`) — Node, scheduler, GSI HTTP-сервер, IPC, окна, tray, updater, persistence
- **preload** (`src/preload/index.ts`) — contextBridge → `window.api`, dev-only методы вырезаны на проде (`import.meta.env.MODE !== 'production'`)
- **renderer** (`src/renderer/src/`) — React, zustand-store, страницы Dashboard/Games/Settings/About, ReminderApp в отдельном окне
### Persistence
- Единственный JSON-файл: `%APPDATA%\Exercise Reminder\app-state.json`
- **Атомарная запись**: tmp + rename + retry на EBUSY/EPERM (антивирус, OneDrive)
- **Не теряет данные**: corrupt JSON → quarantine в `app-state.json.corrupt-<ts>`, не silent wipe
- **Schema migrations**: `__schemaVersion` поле + `MIGRATIONS: Record<number, (s)=>s>` map в `src/main/store.ts`
- **Debounced writes**: pendingWrite с `.unref()`
### IPC
- Типизированные каналы — `src/shared/ipc.ts`
- **Validation layer** — `src/main/validate.ts` (hand-rolled, без zod):
- `intervalMinutes ∈ [1, 1440]`, `reps ∈ [1, 9999]`, `multiplier ∈ [0, 1000]`
- 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}/…` (старая схема ломалась: установленная копия видела только свой релиз)
- Hourly silent auto-check (транзитные сетевые ошибки не показывают красный баннер; только ручной клик показывает ошибку)
- Boot-check: 3 ретрая с backoff 30s/2m/5m
- `lastCheckedAt` → UI «проверено N мин назад»
- Релиз через `scripts/release.ps1` публикует одной командой в:
1. `vX.Y.Z` (постоянный архивный тег)
2. `update-channel` (rolling — клиенты проверяют отсюда)
3. Опциональные `-BridgeTags` для миграции старых пользователей
### Безопасность
- **GSI server** (`src/main/games/gsi-server.ts`): per-install token verify через `timingSafeEqual`, reject Origin/Sec-Fetch-Site (CSRF), 256KB body cap, require `application/json`, generic 400
- **shell.openExternal allowlist**: только `http:`/`https:`/`mailto:` (`src/main/windows.ts`)
- **will-navigate** блокирует non-file:// и non-dev URL
- **Modal focus trap** + focus restore, aria-labelledby
### Quiet hours
- `isQuietAt(time, settings)` в `src/shared/types.ts`
- Wrap-around (22:00 → 07:00) корректно — при wrap-active проверяется день *начала* окна
- Тесты в `src/shared/quiet-hours.test.ts`
### История / стрики
- `src/renderer/src/lib/history.ts` — DST-safe через `shiftDays()` (calendar `setDate`, не ms-арифметика)
- Cap 10k записей, trim oldest 10% на overflow
- HistoryHeatmap: percentile-based bucketing (p25/p50/p85), а не flat ratio (защищает от outlier-дней)
### i18n
- Самописная микро-система: `src/renderer/src/i18n/dict.ts` (плоский словарь ~200 ключей × 2 языка)
- Хук `useT()`, плюрализация CLDR rules для RU (one/few/many)
- Интерполяция через split/join (не regex — защита от regex-инъекций в значениях var)
- Tray menu тоже локализован (`TRAY_STRINGS` в `src/main/tray.ts`)
## Команды
```bash
npm run dev # electron-vite dev
npm run typecheck # tsc по node + web
npm run test:run # vitest один раз
npm run lint # eslint --max-warnings 0
npm run format # prettier --write
npm run dist # сборка + NSIS installer → release/
# Релиз (всё в одном)
npm run release -- -Bump patch
# или -Bump minor / -Bump major / -Version 1.2.3
# опционально: -BridgeTags v0.4.0,v0.4.1
```
## Скрипты релиза
- `scripts/release.ps1` — bump → typecheck → test → build → tag → push → upload в Gitea (vX.Y.Z + update-channel + bridges)
- `scripts/upload-release-assets.ps1` — curl.exe с retry/backoff (15s/45s/2m/5m × 4) на 504/TLS, проверяет уже-залилось через list assets перед ретраем
- **PowerShell 5.1 gotchas**:
- Default reads CP1251 → файлы скриптов **ASCII-only**, без em-dash/кириллицы в коде
- `Set-Content -Encoding utf8` добавляет BOM → ломает PostCSS. Для UTF-8 без BOM использовать `[System.IO.File]::WriteAllText` + `new UTF8Encoding($false)`
- Никогда `-i` флаги (rebase -i, add -i) — нет interactive input
## Gitea remote
- 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 не настроены
- `.gitea/workflows/` пустая (раньше там лежали yml → queued runs копились)
## Файлы, которые часто правлю
| Файл | Что |
|---|---|
| `package.json` | version, publish.url, scripts, deps |
| `src/main/store.ts` | persistence, migrations, validation, atomic writes |
| `src/main/ipc.ts` | IPC handlers с валидацией |
| `src/main/scheduler.ts` | таймеры упражнений, powerMonitor |
| `src/main/games/dota2.ts` + `gsi-server.ts` | GSI приём матчей |
| `src/main/updater.ts` | auto-update logic, silent retries |
| `src/shared/types.ts` | shared типы, дефолты, isQuietAt |
| `src/shared/ipc.ts` | IPC channel types |
| `src/renderer/src/i18n/dict.ts` | словари |
| `src/renderer/src/pages/Dashboard.tsx` | главная |
| `src/renderer/src/ReminderApp.tsx` | окно напоминания |
## Тесты (203)
```
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)
```
Покрываются: 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 состояния между тестами).
## Технический долг (не для пользователя)
- `sandbox: true` на BrowserWindow — нужен smoke-тест preload в sandbox-режиме
- Self-host Google Fonts (сейчас внешняя CSP-зависимость)
- ReminderApp race: первое напоминание может прийти без озвучки до загрузки settings
- Мажорные апдейты (React 18→19, Electron 33→42, Tailwind 3→4) — каждый ломающий, отдельная итерация
- Code-signing NSIS — ~$300/год, уберёт SmartScreen warning
- Скриншоты в README (есть TODO в самом README)
## Стиль кода
- Prettier: semi:false, singleQuote, trailingComma:none, printWidth:80
- ESLint: eslint:recommended + ts + react + react-hooks (без style rules — это Prettier)
- TypeScript strict, никакого `any` в новом коде
- Комментарии на русском там, где объясняют **почему**, не **что**
- Коммиты на русском, формат `тип(scope): кратко` (feat/fix/docs/refactor/test/chore)
- Co-Authored-By футер в коммитах от Claude
## Управление контекстом
- **Ужимать контекст при достижении 250k токенов** — вызывать `/compact` (или эквивалент) когда суммарный контекст подходит к 250 000 токенов. Не дожидаться authentic переполнения и автоматического сжатия от рантайма — сделать это контролируемо, чтобы важный контекст (открытые правки, недокоммиченные решения, текущая ветка задачи) попал в summary, а не выпал.
## Чего НЕ делать
- Не пушить в `update-channel` руками — только через `release.ps1`
- Не добавлять `.gitea/workflows/*.yml` — has_actions выключен, runs зависнут
- Не использовать regex в i18n-интерполяции — только split/join
- Не silent wipe corrupt JSON — quarantine с timestamp
- Не возвращать ms-арифметику в history.ts — DST сломается
- Не убирать validation layer из IPC — compromised renderer может засунуть NaN/негативы
- Не амендить коммиты без явной просьбы пользователя

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 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений. Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
[![release](https://img.shields.io/badge/release-v0.4.0-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest) [![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-33%20passing-green)]() [![tests](https://img.shields.io/badge/tests-203%20passing-green)]()
[![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]() [![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]()
## Что внутри ## Что внутри
@@ -15,7 +15,7 @@ Windows desktop приложение, которое напоминает дел
- **Игровая интеграция (Dota 2)** — Game State Integration читает статистику матча, после Победа/Поражение показывает экран с «причитающимися» повторениями (например `10 смертей × 3 = 30 приседаний`). - **Игровая интеграция (Dota 2)** — Game State Integration читает статистику матча, после Победа/Поражение показывает экран с «причитающимися» повторениями (например `10 смертей × 3 = 30 приседаний`).
- **Apple-style интерфейс** — Plus Jakarta Sans + Bricolage Grotesque, iOS-палитра, vibrancy sidebar, spring-анимации, светлая/тёмная/системная тема. - **Apple-style интерфейс** — Plus Jakarta Sans + Bricolage Grotesque, iOS-палитра, vibrancy sidebar, spring-анимации, светлая/тёмная/системная тема.
- **Два языка** — русский и английский, переключение мгновенное. - **Два языка** — русский и английский, переключение мгновенное.
- **Auto-update** — приложение само скачивает новые версии из Gitea release (проверка каждый час). - **Auto-update** — приложение само скачивает новые версии из фиксированного `update-channel` (проверка каждый час, силент-ретрай при сетевых сбоях).
## Скриншоты ## Скриншоты
@@ -34,7 +34,7 @@ Windows SmartScreen может предупредить «не доверено
## Разработка ## Разработка
```bash ```bash
git clone https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude.git git clone https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude.git
cd laude cd laude
npm install npm install
npm run dev npm run dev
@@ -66,19 +66,32 @@ npm run release -- -Bump patch # bump версии + tag + push + upload в G
## Тесты ## Тесты
``` ```
src/shared/types.test.ts (4) src/main/validate.test.ts (68)
src/renderer/src/lib/format.test.ts (8) src/renderer/src/lib/history.test.ts (31)
src/main/games/vdf.test.ts (11) src/renderer/src/i18n/i18n.test.ts (15)
src/renderer/src/i18n/i18n.test.ts (10) src/renderer/src/lib/format.test.ts (14)
───────────────────────────────────── src/main/games/vdf.test.ts (11)
33 ✓ 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 ## Stack

View File

@@ -1,146 +1,142 @@
# Релиз и автообновления # Релиз и автообновления
Документ описывает три способа выпустить новую версию. Все опираются на Документ описывает, как выпускать новые версии и как устроена система
один и тот же артефакт — NSIS-инсталлятор `Exercise-Reminder-Setup-X.Y.Z.exe`, авто-обновлений.
который сам решает: устанавливать заново или обновлять существующую копию.
## TL;DR ## TL;DR
```pwsh ```pwsh
$env:GITEA_TOKEN = '<token из Gitea Settings → Applications>' $env:GITEA_TOKEN = '<token из Gitea Settings → Applications>'
npm run release -- -Bump patch # 0.2.0 → 0.2.1 npm run release -- -Bump patch # 0.5.1 → 0.5.2
# или npm run release -- -Bump minor -BridgeTags v0.5.0 # 0.5.x → 0.6.0 + bridge
npm run release -- -Version 0.3.0 npm run release -- -Version 1.0.0
``` ```
Скрипт сделает всё сам: бамп версии, коммит, тег, push, тесты, сборка Скрипт делает всё сам: бамп версии, коммит, тег, push, тесты, сборка
инсталлятора, создание Gitea release с заметками из коммитов, загрузка инсталлятора, загрузка в Gitea releases.
артефактов.
После публикации релиза установленные у пользователей копии в течение ## Архитектура auto-update
~6 часов проверят `latest.yml` на Gitea и предложат обновление через UI.
--- ### Где лежат артефакты
## Как работает auto-update Каждый выпуск публикует три файла:
1. На каждом релизе вместе с `.exe` публикуется `latest.yml` ```
манифест с версией, размером, sha512 хешем. Exercise-Reminder-Setup-X.Y.Z.exe # NSIS-инсталлятор (~80 MB)
2. Приложение (через `electron-updater`) каждые 6 часов делает HTTP Exercise-Reminder-Setup-X.Y.Z.exe.blockmap # для differential update (~90 KB)
GET на `<gitea>/AnRil/laude/releases/download/v<current>/latest.yml`. latest.yml # манифест: версия + хеш + размер
3. Если версия в манифесте выше текущей — статус становится ```
`available`, в Settings → Обновления появляется кнопка «Скачать».
4. После скачивания — статус `downloaded`, кнопка «Перезапустить».
5. При перезапуске NSIS установщик из дельты или полный накатывается
поверх существующей инсталляции. Данные в `%APPDATA%\Exercise Reminder\`
сохраняются.
**Важно:** репозиторий `laude` приватный. Чтобы auto-update работал на И они одновременно публикуются в **три-четыре места** на Gitea:
машинах конечных пользователей, либо:
- сделать репозиторий публичным, либо
- сделать публичными только релизы (Gitea: Release Settings),
- либо подписывать запросы токеном (нужен код в `updater.ts`,
использующий `autoUpdater.requestHeaders`).
## Способ 1 — скрипт релиза (рекомендованный сейчас) | Release tag | Назначение |
| ----------------- | ------------------------------------------------------------ |
| `vX.Y.Z` | Архив + changelog для людей |
| `update-channel` | **Фиксированный URL для auto-updater** (никогда не меняется) |
| `vN.M.K` (bridge) | Мост: чтобы клиенты на старых версиях нашли обновление |
Самый прямой путь, не зависит от Gitea Actions runners. ### Что приложение запекает в бинарник
В `package.json``build.publish.url`:
```
https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/download/update-channel
```
Этот URL **никогда не меняется**. Все версии (и сегодняшние, и будущие)
проверяют один и тот же `update-channel/latest.yml`.
### Цикл проверки
1. При запуске и каждый час `electron-updater` делает GET на
`…/update-channel/latest.yml`.
2. Если в манифесте версия выше текущей — Settings → Обновления показывает
«Доступно vX.Y.Z». По клику качается `.exe` (или differential по
`.blockmap`).
3. После скачивания — кнопка «Перезапустить». NSIS обновляет инсталляцию
поверх с сохранением `%APPDATA%\Exercise Reminder\app-state.json`.
### Bridge-теги (миграционный период)
До v0.5.1 publish.url был `…/releases/download/v${version}`у каждой
версии свой адрес. Установленные ранее копии запекли старый URL.
Чтобы они нашли обновление, новые артефакты также заливаются в их
старые releases (флаг `-BridgeTags`).
После того как все клиенты получили v0.5.1 или выше, аргумент
`-BridgeTags` можно перестать использовать — все будущие версии берут
обновления через `update-channel`.
### Поведение при ошибках
- Hourly auto-check работает в **silent**-режиме: сетевые ошибки
логируются в консоль, но **не** показываются как красный баннер.
Следующая попытка через час.
- Boot-check ретраит 3 раза с backoff 30s/2m/5m перед тем как сдаться.
- Только ручной клик «Проверить обновления» показывает ошибку, если
она есть.
## Команды
```pwsh ```pwsh
# Один раз — получить токен в Gitea (Settings Applications) # Один раз — токен из Gitea Settings -> Applications (write:repository).
# и сохранить в переменную окружения. Право — write:repository.
[Environment]::SetEnvironmentVariable('GITEA_TOKEN', '<token>', 'User') [Environment]::SetEnvironmentVariable('GITEA_TOKEN', '<token>', 'User')
# Релиз # Релиз
npm run release -- -Bump patch # patch (0.2.0 → 0.2.1) npm run release -- -Bump patch # patch (0.5.1 -> 0.5.2)
npm run release -- -Bump minor # minor (0.2.0 → 0.3.0) npm run release -- -Bump minor # minor (0.5.x -> 0.6.0)
npm run release -- -Bump major # major (0.2.0 → 1.0.0) npm run release -- -Bump major # major
npm run release -- -Version 1.2.3 # точная версия npm run release -- -Version 1.2.3 # точная версия
npm run release -- -DryRun # посмотреть план без действий npm run release -- -BridgeTags v0.4.0,v0.5.0 # дополнительные мосты
npm run release -- -DryRun # план без действий
``` ```
Что делает скрипт: Что делает `release.ps1`:
1. Проверяет что нет незакоммиченных изменений
2. Бампит версию в `package.json`, коммитит
3. Прогоняет `npm run typecheck` и `npm run test:run`
4. Собирает `npm run dist` (NSIS + блокмап + latest.yml)
5. Создаёт тег `vX.Y.Z`, пушит main и тег в origin
6. Через Gitea API создаёт release с заметками из git log
7. Загружает три файла как assets: `.exe`, `.exe.blockmap`, `latest.yml`
## Способ 2 — Gitea Actions (если есть runners) 1. Проверяет чистоту дерева.
2. Бампит `package.json`, коммитит как `chore(release): vX.Y.Z`.
Workflows лежат в `.gitea/workflows/`: 3. `npm run typecheck` + `npm run test:run`.
4. `npm run dist` → NSIS-инсталлятор + blockmap + latest.yml в `release/`.
- **`ci.yml`** — на push в main и на PR. Запускает typecheck + 5. `git tag vX.Y.Z` и push main + tag в origin.
unit-тесты + smoke-сборку (без NSIS). Кладёт распакованную сборку 6. Через `upload-release-assets.ps1` заливает артефакты в каждый тег
как artifact на 7 дней. из списка: `vX.Y.Z`, `update-channel`, и все `-BridgeTags`.
- **`release.yml`** — на push тега `v*.*.*`. Сверяет тег с версией 7. Каждая заливка ретраит до 4 раз с backoff 15s/45s/2m/5m на 504.
в `package.json`, прогоняет тесты, собирает NSIS-инсталлятор,
создаёт Gitea release с заметками, загружает артефакты.
Чтобы release workflow работал — в репозитории нужен secret
`GITEA_TOKEN` (Gitea Repo Settings → Secrets). Этот же токен может быть
переиспользован из `Способа 1`.
Для запуска release workflow:
```bash
git tag v0.3.0
git push origin v0.3.0
```
## Способ 3 — руками
Если что-то сломалось в автоматизации:
```pwsh
npm run typecheck
npm run test:run
npm run dist
# В release/ появятся:
# Exercise-Reminder-Setup-X.Y.Z.exe
# Exercise-Reminder-Setup-X.Y.Z.exe.blockmap
# latest.yml
```
Затем в Gitea UI: Releases → Draft new release → загрузить три файла.
## Тестирование auto-update ## Тестирование auto-update
Удобный способ проверить, что цикл работает: 1. Установить какую-нибудь старую версию через `.exe` из её release.
2. Релизнуть свежую версию.
3. В установленной копии: Settings → Обновления → Проверить.
4. Должно показать «Доступна vX.Y.Z» с кнопкой «Скачать».
5. Скачать → Перезапустить → проверить версию.
1. Релизнуть `0.x.0` через `npm run release`. Для `npm run dev` auto-updater отключён — статус сразу `unsupported`.
2. Установить полученный `.exe` на машину.
3. Релизнуть `0.x.1` (любой бамп).
4. На установленной копии открыть Settings → Обновления → Проверить.
Должно показать «Доступно обновление v0.x.1».
5. Скачать → Перезапустить → проверить версию в окне «О программе»
(или в Settings).
Для dev-режима (`npm run dev`) auto-updater отключён — статус сразу
становится `unsupported` с пояснением.
## Откат релиза ## Откат релиза
Если опубликовали плохой релиз:
1. Удалить release в Gitea UI (или через API). 1. Удалить release в Gitea UI (или через API).
2. Удалить тег: `git push origin :refs/tags/vX.Y.Z` и локально 2. `git push origin :refs/tags/vX.Y.Z` и `git tag -d vX.Y.Z`.
`git tag -d vX.Y.Z`. 3. `git revert <bump-hash>` (бамп уже запушен).
3. Откатить bump-коммит: `git revert <hash>` или `git reset --hard HEAD~1` 4. Если артефакты успели уехать в `update-channel` — перезалить туда
(если ещё не пушили дальше). предыдущую версию: `pwsh scripts/upload-release-assets.ps1 -Tag update-channel -AssetVersion <previous>`.
4. Релизнуть тот же номер заново — auto-updater на клиентах увидит
тот же манифест и не предложит обновление (если sha512 совпадёт). На практике лучше выпустить hotfix-патч `X.Y.Z+1`, чем откатывать.
Если содержание поменялось — увидит и предложит обновиться. На
практике лучше выпустить hotfix-патч `X.Y.Z+1`, чем переписывать ## Gitea Actions
существующий релиз.
Раньше в `.gitea/workflows/` лежали `ci.yml` и `release.yml`. Они
требуют Gitea Actions runners (отдельная служба, у нас не настроена),
поэтому каждая push-операция оставляла зависший workflow run в Actions
tab. Workflows удалены, has_actions на репозитории выключен,
Actions tab возвращает 404. Если когда-нибудь захочется CI — добавить
обратно `.gitea/workflows/*.yml` + поднять runners.
## Что попадает в установщик ## Что попадает в установщик
См. `build` секцию `package.json`: См. `build.files` в `package.json`:
- `out/**/*` — собранный код (main + preload + renderer) - `out/**/*` — собранный код (main + preload + renderer)
- `resources/**/*` — иконки - `resources/**/*` — иконки
Никаких node_modules, исходников, тестов, README`electron-builder` Без `node_modules`, без исходников, без тестов — `electron-builder`
сам распаковывает и упаковывает только необходимое. сам выбирает только необходимое.

2738
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "laude", "name": "laude",
"version": "0.5.0", "version": "0.5.8",
"description": "Exercise reminder — Windows desktop app", "description": "Exercise reminder — Windows desktop app",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "AnRil", "author": "AnRil",
@@ -14,6 +14,10 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck": "npm run typecheck:node && npm run typecheck:web",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"*.{json,md}\" \".github/**/*.yml\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{json,md}\"",
"lint": "eslint src --ext .ts,.tsx --max-warnings 0",
"dist": "electron-vite build && electron-builder --win --x64", "dist": "electron-vite build && electron-builder --win --x64",
"dist:dir": "electron-vite build && electron-builder --win --x64 --dir", "dist:dir": "electron-vite build && electron-builder --win --x64 --dir",
"publish": "electron-vite build && electron-builder --win --x64 --publish always", "publish": "electron-vite build && electron-builder --win --x64 --publish always",
@@ -21,6 +25,9 @@
"gen:icons": "powershell -ExecutionPolicy Bypass -File scripts/gen-icons.ps1" "gen:icons": "powershell -ExecutionPolicy Bypass -File scripts/gen-icons.ps1"
}, },
"dependencies": { "dependencies": {
"@fontsource/bricolage-grotesque": "^5.2.10",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/plus-jakarta-sans": "^5.2.8",
"electron-updater": "^6.8.3", "electron-updater": "^6.8.3",
"framer-motion": "^11.11.17", "framer-motion": "^11.11.17",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
@@ -33,12 +40,18 @@
"@types/node": "^22.19.19", "@types/node": "^22.19.19",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"electron": "^33.2.0", "electron": "^33.2.0",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"electron-vite": "^2.3.0", "electron-vite": "^2.3.0",
"eslint": "^8.57.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.4.1",
"tailwindcss": "^3.4.15", "tailwindcss": "^3.4.15",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.11", "vite": "^5.4.11",
@@ -88,7 +101,7 @@
}, },
"publish": { "publish": {
"provider": "generic", "provider": "generic",
"url": "https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/download/v${version}", "url": "https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/download/update-channel",
"channel": "latest" "channel": "latest"
} }
} }

View File

@@ -1,67 +1,78 @@
<# <#
.SYNOPSIS .SYNOPSIS
Локальный релиз: бамп версии коммит тег push сборка upload в Gitea release. Локальный релиз: бамп версии -> коммит -> тег -> push -> сборка -> upload в Gitea.
.DESCRIPTION .DESCRIPTION
Один скрипт от и до. Если Gitea Actions не настроено, это рабочая альтернатива. Single-command release flow.
Каждый релиз публикует артефакты в ТРИ места:
1. Тег vX.Y.Z (исторический архив + changelog)
2. Тег update-channel (фиксированный URL для auto-updater)
3. Bridge-теги, указанные в -BridgeTags (для миграции пользователей со
старых версий, у которых запечён старый publish.url).
После того как все пользователи получили версию с новым (фиксированным)
publish.url, аргумент -BridgeTags можно перестать указывать.
.PARAMETER Bump .PARAMETER Bump
Какую часть semver инкрементировать: patch (по умолчанию), minor, major. Какую часть semver инкрементировать: patch (по умолчанию), minor, major.
Альтернатива — указать -Version явно.
.PARAMETER Version .PARAMETER Version
Точная версия (напр. "0.3.0"). Если задана, -Bump игнорируется. Точная версия (напр. "0.5.1"). Если задана, -Bump игнорируется.
.PARAMETER SkipBuild .PARAMETER SkipBuild
Пропустить сборку (если уже собрано вручную, .exe лежит в release/). Пропустить сборку (если уже собрано вручную, .exe лежит в release/).
.PARAMETER BridgeTags
Список старых тегов, в которые нужно ТАКЖЕ перезалить новые артефакты,
чтобы пользователи на этих версиях нашли апдейт. Например: v0.4.0,v0.5.0.
.PARAMETER DryRun .PARAMETER DryRun
Показать что произойдёт, но ничего не делать. Показать что произойдёт, ничего не делая.
.EXAMPLE .EXAMPLE
pwsh scripts/release.ps1 -Bump minor pwsh scripts/release.ps1 -Bump patch
pwsh scripts/release.ps1 -Version 0.3.0 pwsh scripts/release.ps1 -Version 0.5.1 -BridgeTags v0.4.0,v0.5.0
pwsh scripts/release.ps1 -Bump patch -DryRun
.NOTES .NOTES
Требует переменную окружения GITEA_TOKEN с правом write:repository Требует GITEA_TOKEN с правом write:repository.
(создаётся в Gitea: Settings → Applications → Generate New Token). Канал 'update-channel' должен существовать на Gitea (создаётся однократно).
#> #>
param( param(
[ValidateSet('patch', 'minor', 'major')] [ValidateSet('patch', 'minor', 'major')]
[string]$Bump = 'patch', [string]$Bump = 'patch',
[string]$Version, [string]$Version,
[switch]$SkipBuild, [switch]$SkipBuild,
[string[]]$BridgeTags = @(),
[switch]$DryRun [switch]$DryRun
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
# --- Config ---------------------------------------------------------------
$repoOwner = 'AnRil' $repoOwner = 'AnRil'
$repoName = 'laude' $repoName = 'laude'
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git' $giteaHost = 'git.xn--90adajar8af4h.xn--p1ai'
$apiBase = "https://$giteaHost/api/v1" $channelTag = 'update-channel'
# --- Pre-flight checks --------------------------------------------------- # --- Pre-flight ----------------------------------------------------------
$root = Resolve-Path (Join-Path $PSScriptRoot '..') $root = Resolve-Path (Join-Path $PSScriptRoot '..')
Set-Location $root Set-Location $root
if (-not $env:GITEA_TOKEN -and -not $DryRun) { if (-not $env:GITEA_TOKEN -and -not $DryRun) {
Write-Error 'GITEA_TOKEN не задан. Создай в Gitea Settings → Applications и export GITEA_TOKEN=...' Write-Error 'GITEA_TOKEN not set.'
exit 1 exit 1
} }
$status = git status --porcelain $status = git status --porcelain
if ($status) { if ($status) {
Write-Error "Есть незакоммиченные изменения. Сначала закоммить или stash." Write-Error "Uncommitted changes. Commit or stash first."
exit 1 exit 1
} }
$branch = git rev-parse --abbrev-ref HEAD $branch = git rev-parse --abbrev-ref HEAD
if ($branch -ne 'main') { if ($branch -ne 'main') {
Write-Warning "Текущая ветка не main, а $branch. Продолжить? (Ctrl+C для отмены)" Write-Warning "Branch is $branch, not main. Press Enter to continue or Ctrl+C to cancel."
Read-Host 'Press Enter' Read-Host
} }
# --- Compute next version ------------------------------------------------ # --- Compute next version ------------------------------------------------
@@ -83,109 +94,75 @@ if ($Version) {
$tag = "v$next" $tag = "v$next"
Write-Host "" Write-Host ""
Write-Host "Release plan" -ForegroundColor Cyan Write-Host "Release plan" -ForegroundColor Cyan
Write-Host " current : v$current" Write-Host " current : v$current"
Write-Host " next : $tag" Write-Host " next : $tag"
Write-Host " bump : $Bump" Write-Host " publish into : $tag, $channelTag$(if ($BridgeTags) { ', ' + ($BridgeTags -join ', ') })"
Write-Host "" Write-Host ""
if ($DryRun) { if ($DryRun) {
Write-Host '(dry run exiting)' -ForegroundColor Yellow Write-Host '(dry run - exiting)' -ForegroundColor Yellow
exit 0 exit 0
} }
# --- Bump version in package.json --------------------------------------- # --- Bump package.json --------------------------------------------------
Write-Host "→ Bumping package.json to $next" -ForegroundColor Cyan # IMPORTANT: read+write as UTF-8 WITHOUT BOM. PS5.1 defaults will (a) read the
$pkgJson = (Get-Content package.json -Raw) -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`"" # file as CP1251 and mangle non-ASCII chars like em-dash, and (b) write back
Set-Content -Path package.json -Value $pkgJson -NoNewline -Encoding utf8 # with a BOM that breaks PostCSS / electron-builder reads of package.json.
Write-Host "Bumping package.json to $next..." -ForegroundColor Cyan
$pkgPath = Join-Path $root 'package.json'
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
$pkgJson = [System.IO.File]::ReadAllText($pkgPath, $utf8NoBom)
$pkgJson = $pkgJson -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`""
[System.IO.File]::WriteAllText($pkgPath, $pkgJson, $utf8NoBom)
git add package.json git add package.json
git commit -m "chore(release): $tag" git commit -m "chore(release): $tag"
# --- Build (typecheck + tests + dist) ------------------------------------ # --- Quality gates ------------------------------------------------------
if (-not $SkipBuild) { if (-not $SkipBuild) {
Write-Host "→ Running typecheck" -ForegroundColor Cyan Write-Host "Typecheck..." -ForegroundColor Cyan
npm run typecheck npm run typecheck
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "→ Running tests" -ForegroundColor Cyan Write-Host "Tests..." -ForegroundColor Cyan
npm run test:run npm run test:run
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Building installer (npm run dist)…" -ForegroundColor Cyan Write-Host "Building installer..." -ForegroundColor Cyan
npm run dist npm run dist
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} }
# --- Verify artifacts exist --------------------------------------------- # --- Verify artifacts ---------------------------------------------------
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$next.exe" $installer = Join-Path 'release' "Exercise-Reminder-Setup-$next.exe"
$blockmap = "$installer.blockmap" $blockmap = "$installer.blockmap"
$manifest = Join-Path 'release' 'latest.yml' $manifest = Join-Path 'release' 'latest.yml'
foreach ($f in @($installer, $blockmap, $manifest)) { foreach ($f in @($installer, $blockmap, $manifest)) {
if (-not (Test-Path $f)) { if (-not (Test-Path $f)) {
Write-Error "Не найден артефакт: $f" Write-Error "Artifact missing: $f"
exit 1 exit 1
} }
} }
# --- Tag + push ---------------------------------------------------------- # --- Tag + push ---------------------------------------------------------
Write-Host "Tagging $tag and pushing" -ForegroundColor Cyan Write-Host "Tagging $tag and pushing..." -ForegroundColor Cyan
git tag -a $tag -m "Release $tag" git tag -a $tag -m "Release $tag"
git push origin main git push origin main
git push origin $tag git push origin $tag
# --- Create release via Gitea API ---------------------------------------- # --- Upload to all target releases --------------------------------------
Write-Host "→ Creating Gitea release $tag" -ForegroundColor Cyan $uploadScript = Join-Path $PSScriptRoot 'upload-release-assets.ps1'
$headers = @{
Authorization = "token $env:GITEA_TOKEN"
Accept = 'application/json'
}
# Release notes from commits since previous tag $targets = @($tag, $channelTag) + $BridgeTags
$prev = git describe --tags --abbrev=0 "$tag^" 2>$null foreach ($target in $targets) {
if ($prev) { Write-Host ""
$log = git log --pretty=format:"- %s" "$prev..$tag" | Out-String Write-Host "==> Uploading $next artifacts into release '$target'" -ForegroundColor Cyan
} else { & powershell -ExecutionPolicy Bypass -File $uploadScript -Tag $target -AssetVersion $next
$log = git log --pretty=format:"- %s" "$tag" | Out-String if ($LASTEXITCODE -ne 0) {
} Write-Error "Upload to '$target' failed (exit $LASTEXITCODE)"
$body = @" exit $LASTEXITCODE
### Изменения }
$log
---
**Установщик ниже** запустить и следовать мастеру. Если приложение уже стояло обновится поверх, настройки сохранятся.
"@
$releaseBody = @{
tag_name = $tag
name = "Exercise Reminder $tag"
body = $body
draft = $false
prerelease = $false
} | ConvertTo-Json -Depth 5
$release = Invoke-RestMethod `
-Uri "$apiBase/repos/$repoOwner/$repoName/releases" `
-Method Post `
-Headers $headers `
-Body $releaseBody `
-ContentType 'application/json'
Write-Host " Release id: $($release.id)" -ForegroundColor DarkGray
# --- Upload assets -------------------------------------------------------
foreach ($asset in @($installer, $blockmap, $manifest)) {
$name = Split-Path $asset -Leaf
Write-Host "→ Uploading $name" -ForegroundColor Cyan
$uri = "$apiBase/repos/$repoOwner/$repoName/releases/$($release.id)/assets?name=$([uri]::EscapeDataString($name))"
Invoke-RestMethod `
-Uri $uri `
-Method Post `
-Headers $headers `
-InFile $asset `
-ContentType 'application/octet-stream' | Out-Null
} }
$releaseUrl = "https://$giteaHost/$repoOwner/$repoName/releases/tag/$tag" $releaseUrl = "https://$giteaHost/$repoOwner/$repoName/releases/tag/$tag"
@@ -193,4 +170,4 @@ Write-Host ""
Write-Host "Release published" -ForegroundColor Green Write-Host "Release published" -ForegroundColor Green
Write-Host " $releaseUrl" Write-Host " $releaseUrl"
Write-Host "" Write-Host ""
Write-Host "Auto-updater подхватит обновление на установленных копиях в течение ~6 часов." Write-Host "Auto-updater will pick up the new version within ~1 hour on all installed copies."

View File

@@ -1,29 +1,38 @@
<# <#
.SYNOPSIS .SYNOPSIS
Upload pre-built NSIS artifacts to an existing Gitea release. Upload pre-built NSIS artifacts to a Gitea release.
.DESCRIPTION .DESCRIPTION
Use when the tag v* is already pushed (e.g. release.ps1 succeeded up to Uploads installer + blockmap + latest.yml to the release identified by -Tag.
push but failed on upload, or release was created manually without assets). If the release does not exist it is created (only for semver-looking tags;
If a release for the tag does not exist yet, it is created. If it exists, for non-semver tags like 'update-channel' the release must exist already).
same-name assets are replaced. Same-named existing assets are replaced.
.PARAMETER Tag .PARAMETER Tag
Version tag, e.g. v0.3.0. Defaults to v<package.json version>. Release tag to upload INTO. May be a version tag (v0.5.1) or a channel
tag (update-channel). Defaults to v<package.json version>.
.PARAMETER AssetVersion
Version of the artifacts being uploaded (e.g. 0.5.1). Defaults to the
numeric part of -Tag. Specify explicitly when uploading version-X.Y.Z
artifacts into a non-version tag (channel or bridge).
.EXAMPLE .EXAMPLE
pwsh scripts/upload-release-assets.ps1 pwsh scripts/upload-release-assets.ps1
pwsh scripts/upload-release-assets.ps1 -Tag v0.3.0 pwsh scripts/upload-release-assets.ps1 -Tag v0.5.0
pwsh scripts/upload-release-assets.ps1 -Tag update-channel -AssetVersion 0.5.1
pwsh scripts/upload-release-assets.ps1 -Tag v0.4.0 -AssetVersion 0.5.1
#> #>
param( param(
[string]$Tag [string]$Tag,
[string]$AssetVersion
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$repoOwner = 'AnRil' $repoOwner = 'AnRil'
$repoName = 'laude' $repoName = 'laude'
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git' $giteaHost = 'git.xn--90adajar8af4h.xn--p1ai'
$apiBase = "https://$giteaHost/api/v1" $apiBase = "https://$giteaHost/api/v1"
if (-not $env:GITEA_TOKEN) { if (-not $env:GITEA_TOKEN) {
@@ -35,10 +44,18 @@ $root = Resolve-Path (Join-Path $PSScriptRoot '..')
Set-Location $root Set-Location $root
if (-not $Tag) { if (-not $Tag) {
$version = (Get-Content package.json | ConvertFrom-Json).version $pkgVersion = (Get-Content package.json | ConvertFrom-Json).version
$Tag = "v$version" $Tag = "v$pkgVersion"
} }
$version = $Tag.TrimStart('v') if (-not $AssetVersion) {
# Derive from tag when possible (vX.Y.Z -> X.Y.Z); otherwise read package.json.
if ($Tag -match '^v\d+\.\d+\.\d+') {
$AssetVersion = $Tag.TrimStart('v')
} else {
$AssetVersion = (Get-Content package.json | ConvertFrom-Json).version
}
}
$version = $AssetVersion
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$version.exe" $installer = Join-Path 'release' "Exercise-Reminder-Setup-$version.exe"
$blockmap = "$installer.blockmap" $blockmap = "$installer.blockmap"
@@ -66,6 +83,10 @@ try {
Write-Host " Found release id=$($release.id)" -ForegroundColor DarkGray Write-Host " Found release id=$($release.id)" -ForegroundColor DarkGray
} catch { } catch {
if ($_.Exception.Response.StatusCode.value__ -eq 404) { if ($_.Exception.Response.StatusCode.value__ -eq 404) {
if ($Tag -notmatch '^v\d+\.\d+\.\d+') {
Write-Error "Release '$Tag' not found and tag is not semver. Create it manually on Gitea (e.g. 'update-channel' is a one-time setup)."
exit 1
}
Write-Host " Not found, creating new release..." -ForegroundColor DarkGray Write-Host " Not found, creating new release..." -ForegroundColor DarkGray
$prev = $null $prev = $null
@@ -80,7 +101,7 @@ try {
if ($prev) { if ($prev) {
$log = (& git log --pretty=format:"- %s" "$prev..$Tag") -join "`n" $log = (& git log --pretty=format:"- %s" "$prev..$Tag") -join "`n"
} else { } else {
# No prior tag list last 10 commits up to this tag. # No prior tag - list last 10 commits up to this tag.
$log = (& git log --pretty=format:"- %s" -n 10 "$Tag") -join "`n" $log = (& git log --pretty=format:"- %s" -n 10 "$Tag") -join "`n"
} }
$body = "### Changes`n`n$log`n`n---`n`nInstaller below: run it; if app is already installed, it updates in place and keeps your settings." $body = "### Changes`n`n$log`n`n---`n`nInstaller below: run it; if app is already installed, it updates in place and keeps your settings."
@@ -136,21 +157,66 @@ if ($curlCmd) {
} }
} }
$maxRetries = 4
$backoffs = @(15, 45, 120, 300) # seconds between attempts
foreach ($asset in @($installer, $blockmap, $manifest)) { foreach ($asset in @($installer, $blockmap, $manifest)) {
$name = Split-Path $asset -Leaf $name = Split-Path $asset -Leaf
$size = (Get-Item $asset).Length $size = (Get-Item $asset).Length
Write-Host ("Uploading {0} ({1:N1} MB)..." -f $name, ($size / 1MB)) -ForegroundColor Cyan
$uri = "$apiBase/repos/$repoOwner/$repoName/releases/$($release.id)/assets?name=$([uri]::EscapeDataString($name))" $uri = "$apiBase/repos/$repoOwner/$repoName/releases/$($release.id)/assets?name=$([uri]::EscapeDataString($name))"
# -f: fail on HTTP errors; -s -S: silent but show errors; --data-binary @file
& $curl ` $attempt = 0
--fail-with-body ` $uploaded = $false
--silent --show-error ` while (-not $uploaded -and $attempt -le $maxRetries) {
-H "Authorization: token $env:GITEA_TOKEN" ` if ($attempt -gt 0) {
-H "Content-Type: application/octet-stream" ` $wait = $backoffs[[Math]::Min($attempt - 1, $backoffs.Length - 1)]
--data-binary "@$asset" ` Write-Host (" Retrying in {0}s (attempt {1}/{2})..." -f $wait, ($attempt + 1), ($maxRetries + 1)) -ForegroundColor Yellow
$uri Start-Sleep -Seconds $wait
if ($LASTEXITCODE -ne 0) {
Write-Error "Upload failed for $name (curl exit $LASTEXITCODE)" # Re-check whether prior attempt actually succeeded server-side before
# 504-ing the client. If asset is already there, treat as success.
try {
$check = Invoke-RestMethod `
-Uri "$apiBase/repos/$repoOwner/$repoName/releases/$($release.id)/assets" `
-Method Get -Headers $headers
$existing = $check | Where-Object { $_.name -eq $name }
if ($existing -and $existing.size -eq $size) {
Write-Host " Asset already present server-side ($($existing.size) bytes) - skipping retry." -ForegroundColor DarkGray
$uploaded = $true
break
}
# If asset is present but with wrong size (half-uploaded), delete first.
if ($existing) {
Write-Host " Removing partial asset id=$($existing.id) ($($existing.size) bytes) before retry..." -ForegroundColor DarkGray
Invoke-RestMethod `
-Uri "$apiBase/repos/$repoOwner/$repoName/releases/$($release.id)/assets/$($existing.id)" `
-Method Delete -Headers $headers | Out-Null
}
} catch {
# If the list call itself fails, just proceed with the retry.
}
}
Write-Host ("Uploading {0} ({1:N1} MB)..." -f $name, ($size / 1MB)) -ForegroundColor Cyan
& $curl `
--fail-with-body `
--silent --show-error `
--connect-timeout 30 `
--max-time 900 `
-H "Authorization: token $env:GITEA_TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$asset" `
$uri
if ($LASTEXITCODE -eq 0) {
$uploaded = $true
} else {
Write-Host " curl exit $LASTEXITCODE - will retry." -ForegroundColor Yellow
$attempt++
}
}
if (-not $uploaded) {
Write-Error "Upload failed for $name after $($maxRetries + 1) attempts."
exit 1 exit 1
} }
} }

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

94
src/main/adaptive.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* Адаптивный шедулер v1: смотрит на исторические success/skip-паттерны
* по часам дня и сдвигает следующий fire на ближайший «хороший» час.
*
* Без ML, чистая heuristic. Идея: каждому часу 0..23 присваиваем
* success-rate = done / (done + skip + snooze). Если интервал шедулера
* приходится на «плохой» час (success ≤ 30%), сдвигаем fire вперёд на
* ближайший «хороший» (≥ 50% success или не имеющий данных = neutral).
*
* Окно учитываемой истории: 30 дней. Требует минимум 10 событий по
* упражнению, иначе истории слишком мало для статистики — возвращаем
* candidate без изменений.
*
* Этот модуль НЕ обязателен для работы шедулера; вызывается опционально
* когда у упражнения `adaptive: true`.
*/
import type { Exercise, HistoryEntry } from '@shared/types'
const LOOKBACK_MS = 30 * 24 * 60 * 60 * 1000 // 30 дней
const MIN_EVENTS_FOR_TRUST = 10
const BAD_HOUR_THRESHOLD = 0.3
const GOOD_HOUR_THRESHOLD = 0.5
/** Максимальный сдвиг — чтобы не утащить fire на 6 часов вперёд. */
const MAX_SHIFT_HOURS = 4
type HourStats = {
done: number
skipped: number
total: number
}
function buildHourStats(
exerciseId: string,
history: HistoryEntry[]
): HourStats[] {
const cutoff = Date.now() - LOOKBACK_MS
const stats: HourStats[] = Array.from({ length: 24 }, () => ({
done: 0,
skipped: 0,
total: 0
}))
for (const e of history) {
if (e.exerciseId !== exerciseId) continue
if (e.ts < cutoff) continue
const h = new Date(e.ts).getHours()
stats[h].total++
if (e.action === 'done') stats[h].done++
else stats[h].skipped++ // skip + snooze оба считаются «не done»
}
return stats
}
function isHourGood(s: HourStats): boolean {
if (s.total === 0) return true // нет данных — нейтрально, ОК
return s.done / s.total >= GOOD_HOUR_THRESHOLD
}
function isHourBad(s: HourStats): boolean {
if (s.total < 3) return false // данных мало, не делаем выводы
return s.done / s.total <= BAD_HOUR_THRESHOLD
}
/**
* Возвращает скорректированный timestamp следующего fire. Если candidate
* попадает на «плохой» час и есть «хороший» час в пределах MAX_SHIFT_HOURS,
* сдвигаем на начало этого хорошего часа. Иначе возвращаем candidate
* как есть.
*/
export function adjustNextFireAt(
exercise: Exercise,
candidateTs: number,
history: HistoryEntry[]
): number {
// Сначала собираем total — если истории мало, не двигаем.
const stats = buildHourStats(exercise.id, history)
const total = stats.reduce((s, h) => s + h.total, 0)
if (total < MIN_EVENTS_FOR_TRUST) return candidateTs
const candDate = new Date(candidateTs)
const candHour = candDate.getHours()
if (!isHourBad(stats[candHour])) return candidateTs
// Ищем ближайший «хороший» час в будущем (внутри MAX_SHIFT_HOURS).
for (let shift = 1; shift <= MAX_SHIFT_HOURS; shift++) {
const h = (candHour + shift) % 24
if (isHourGood(stats[h])) {
const target = new Date(candDate)
target.setHours(candHour + shift, 0, 0, 0)
return target.getTime()
}
}
// Не нашли — оставляем как есть.
return candidateTs
}

View File

@@ -17,5 +17,8 @@ export function isAutostartEnabled(): boolean {
} }
export function wasStartedHidden(): boolean { export function wasStartedHidden(): boolean {
return process.argv.includes(HIDDEN_FLAG) || app.getLoginItemSettings().wasOpenedAsHidden return (
process.argv.includes(HIDDEN_FLAG) ||
app.getLoginItemSettings().wasOpenedAsHidden
)
} }

View File

@@ -1,6 +1,12 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import {
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { randomBytes } from 'node:crypto' import { randomBytes, timingSafeEqual } from 'node:crypto'
import { app } from 'electron' import { app } from 'electron'
import type { GameProvider, ProviderEventHandler } from './provider' import type { GameProvider, ProviderEventHandler } from './provider'
import { findGameInstall } from './steam' import { findGameInstall } from './steam'
@@ -12,6 +18,7 @@ import {
isSteamRunning isSteamRunning
} from './steam-launch-options' } from './steam-launch-options'
import type { GameId, GameStatus, LaunchOptionStatus } from '@shared/types' import type { GameId, GameStatus, LaunchOptionStatus } from '@shared/types'
import { log } from '../logger'
const APP_ID = '570' const APP_ID = '570'
const INSTALL_DIR = 'dota 2 beta' const INSTALL_DIR = 'dota 2 beta'
@@ -21,6 +28,7 @@ const LAUNCH_OPTION = '-gamestateintegration'
type DotaGsi = { type DotaGsi = {
provider?: { name?: string } provider?: { name?: string }
auth?: { token?: string }
map?: { map?: {
game_state?: string game_state?: string
win_team?: 'radiant' | 'dire' | 'none' win_team?: 'radiant' | 'dire' | 'none'
@@ -38,6 +46,19 @@ type DotaGsi = {
} }
} }
/**
* Constant-time string equality. Avoids early-exit timing oracles that could
* leak the token byte-by-byte to a local attacker who can measure response
* latency on the loopback HTTP server. (Practical risk is tiny; correctness
* matters anyway.)
*/
function safeEqualStrings(a: string, b: string): boolean {
const A = Buffer.from(a, 'utf-8')
const B = Buffer.from(b, 'utf-8')
if (A.length !== B.length) return false
return timingSafeEqual(A, B)
}
function tokenStorePath(): string { function tokenStorePath(): string {
return join(app.getPath('userData'), 'dota2-gsi-token.txt') return join(app.getPath('userData'), 'dota2-gsi-token.txt')
} }
@@ -115,7 +136,10 @@ export class Dota2Provider implements GameProvider {
if (present) launchOptionStatus = 'applied' if (present) launchOptionStatus = 'applied'
else { else {
steamRunning = await isSteamRunning() steamRunning = await isSteamRunning()
launchOptionStatus = steamRunning ? 'queued' : 'queued' // Either Steam is open (we can't write while it runs -> 'queued') or
// closed (apply on next ensureLaunchOption call -> still queued until
// the watcher tick actually writes). 'queued' is correct for both.
launchOptionStatus = 'queued'
} }
} }
return { return {
@@ -134,7 +158,8 @@ export class Dota2Provider implements GameProvider {
async install(): Promise<void> { async install(): Promise<void> {
if (!this.installPath) { if (!this.installPath) {
const status = await this.detect() const status = await this.detect()
if (!status.installPath) throw new Error('Dota 2 не найдена в Steam-библиотеках') if (!status.installPath)
throw new Error('Dota 2 не найдена в Steam-библиотеках')
} }
const dir = cfgDir(this.installPath!) const dir = cfgDir(this.installPath!)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
@@ -157,7 +182,13 @@ export class Dota2Provider implements GameProvider {
async start(emit: ProviderEventHandler): Promise<void> { async start(emit: ProviderEventHandler): Promise<void> {
this.emit = emit this.emit = emit
this.unregister = registerGsiRoute(ROUTE, (payload) => this.handle(payload as DotaGsi)) // Defensive double-register guard: free any previous registration first.
this.unregister?.()
this.unregister = registerGsiRoute(ROUTE, (payload) => {
// Runtime shape check — payload comes from a network socket.
if (typeof payload !== 'object' || payload === null) return
this.handle(payload as DotaGsi)
})
} }
async stop(): Promise<void> { async stop(): Promise<void> {
@@ -168,11 +199,46 @@ export class Dota2Provider implements GameProvider {
this.latest = undefined this.latest = undefined
} }
private handle(g: DotaGsi): void { private rejectedTokenLogged = false
// Track latest snapshot so we have stats when the transition fires.
if (g.player || g.map) this.latest = { ...this.latest, ...g, player: { ...this.latest?.player, ...g.player }, map: { ...this.latest?.map, ...g.map } }
const state = g.map?.game_state ?? this.latest?.map?.game_state private handle(g: DotaGsi): void {
// Verify the per-install token. Dota always sends auth.token; anything
// without it (or with the wrong one) is some other process on localhost
// trying to fake a match-end event.
const incoming = g.auth?.token
if (
typeof incoming !== 'string' ||
!safeEqualStrings(incoming, this.token)
) {
// Логируем только ОДИН раз за процесс — Dota шлёт payload каждые
// ~100ms во время матча, иначе zass'мём latest.log.
if (!this.rejectedTokenLogged) {
this.rejectedTokenLogged = true
log.warn(
'[dota2] GSI payload with invalid/missing token rejected. ' +
'Если приложение переустанавливалось — заново подключи Dota 2 в Games.'
)
}
return
}
// Narrow the shape before spread-merging. A payload like `{player:"x"}`
// would otherwise let `{...this.latest?.player, ...g.player}` throw.
const playerObj =
typeof g.player === 'object' && g.player !== null ? g.player : undefined
const mapObj =
typeof g.map === 'object' && g.map !== null ? g.map : undefined
if (playerObj || mapObj) {
this.latest = {
...this.latest,
...g,
player: { ...this.latest?.player, ...playerObj },
map: { ...this.latest?.map, ...mapObj }
}
}
const state = mapObj?.game_state ?? this.latest?.map?.game_state
if (!state) return if (!state) return
const prev = this.prevState const prev = this.prevState
@@ -181,8 +247,12 @@ export class Dota2Provider implements GameProvider {
if (prev && prev !== state && state === 'DOTA_GAMERULES_STATE_POST_GAME') { if (prev && prev !== state && state === 'DOTA_GAMERULES_STATE_POST_GAME') {
// De-dupe: Dota can fire POST_GAME repeatedly while the scoreboard is open. // De-dupe: Dota can fire POST_GAME repeatedly while the scoreboard is open.
const now = Date.now() const now = Date.now()
if (now - this.lastMatchEndAt < 30_000) return if (now - this.lastMatchEndAt < 30_000) {
log.debug('[dota2] suppressed duplicate POST_GAME within 30s window')
return
}
this.lastMatchEndAt = now this.lastMatchEndAt = now
log.info('[dota2] POST_GAME detected, emitting match_end event')
const p = this.latest?.player ?? {} const p = this.latest?.player ?? {}
const m = this.latest?.map ?? {} const m = this.latest?.map ?? {}
@@ -209,6 +279,11 @@ export class Dota2Provider implements GameProvider {
} }
} }
}) })
// Reset stale state so the NEXT match starts from a clean slate even if
// the user re-enters the same lobby or Dota's GSI restarts mid-session.
this.latest = undefined
this.prevState = undefined
} }
} }
} }

View File

@@ -1,22 +1,64 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse
} from 'node:http'
import { log } from '../logger'
export type GsiHandler = (payload: unknown, headers: Record<string, string | string[] | undefined>) => void export type GsiHandler = (
payload: unknown,
headers: Record<string, string | string[] | undefined>
) => void
const PORT = 4701 const PORT = 4701
/**
* Hard cap on incoming POST body. Real Dota GSI payloads are ~8 KB; anything
* larger is either a bug or a malicious local client trying to OOM us.
*/
const MAX_BODY_BYTES = 256 * 1024
let server: Server | null = null let server: Server | null = null
const handlers: Map<string, GsiHandler> = new Map() const handlers: Map<string, GsiHandler> = new Map()
function getBody(req: IncomingMessage): Promise<Buffer> { function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let received = 0
const chunks: Buffer[] = [] const chunks: Buffer[] = []
req.on('data', (c) => chunks.push(c as Buffer)) req.on('data', (c: Buffer) => {
received += c.length
if (received > MAX_BODY_BYTES) {
// Drop the connection so we don't keep buffering.
req.destroy(new Error('body too large'))
reject(new Error('body too large'))
return
}
chunks.push(c)
})
req.on('end', () => resolve(Buffer.concat(chunks))) req.on('end', () => resolve(Buffer.concat(chunks)))
req.on('error', reject) req.on('error', reject)
}) })
} }
async function onRequest(req: IncomingMessage, res: ServerResponse): Promise<void> { async function onRequest(
req: IncomingMessage,
res: ServerResponse
): Promise<void> {
// Reject browser-originated requests outright. Legitimate Dota GSI POSTs
// never include an Origin header; any value here means a webpage is poking
// our localhost endpoint via cross-origin fetch, which we never want.
if (req.headers.origin) {
res.statusCode = 403
res.end()
return
}
// Same intent for Sec-Fetch-Site: browsers always set it, Dota never does.
if (req.headers['sec-fetch-site']) {
res.statusCode = 403
res.end()
return
}
const route = (req.url ?? '/').split('?')[0] const route = (req.url ?? '/').split('?')[0]
const handler = handlers.get(route) const handler = handlers.get(route)
if (!handler) { if (!handler) {
@@ -29,17 +71,38 @@ async function onRequest(req: IncomingMessage, res: ServerResponse): Promise<voi
res.end() res.end()
return return
} }
// Require JSON content-type. Browsers' "simple" requests in no-cors mode
// can only send text/plain or form-encoded — locking to application/json
// shrinks the cross-origin attack surface further.
const ct = String(req.headers['content-type'] ?? '').toLowerCase()
if (!ct.includes('application/json')) {
res.statusCode = 415
res.end()
return
}
let payload: unknown
try { try {
const body = await getBody(req) const body = await readBody(req)
const text = body.toString('utf-8') const text = body.toString('utf-8')
const payload = text.length > 0 ? JSON.parse(text) : {} payload = text.length > 0 ? JSON.parse(text) : {}
} catch (err) {
// Log the real reason locally; do not echo it to the client.
log.warn('[gsi] bad request', err instanceof Error ? err.message : err)
res.statusCode = 400
res.end()
return
}
try {
handler(payload, req.headers) handler(payload, req.headers)
res.statusCode = 200 res.statusCode = 200
res.setHeader('Content-Type', 'text/plain') res.setHeader('Content-Type', 'text/plain')
res.end('ok') res.end('ok')
} catch (err) { } catch (err) {
log.error('[gsi] handler threw', err)
res.statusCode = 500 res.statusCode = 500
res.end(String(err)) res.end()
} }
} }
@@ -52,16 +115,26 @@ export async function startGsiServer(): Promise<void> {
}) })
} }
export function stopGsiServer(): void { export async function stopGsiServer(): Promise<void> {
if (server) { if (!server) return
server.close() const s = server
server = null server = null
} // Free pending sockets so close() can resolve quickly even while Dota holds
// a long-poll connection open.
s.closeAllConnections?.()
await new Promise<void>((resolve) => s.close(() => resolve()))
} }
export function registerGsiRoute(route: string, handler: GsiHandler): () => void { export function registerGsiRoute(
route: string,
handler: GsiHandler
): () => void {
handlers.set(route, handler) handlers.set(route, handler)
return () => handlers.delete(route) return () => {
// Only delete if we're still the registered handler — protects against
// double-register + unregister races where a newer handler took our slot.
if (handlers.get(route) === handler) handlers.delete(route)
}
} }
export function getGsiBaseUrl(): string { export function getGsiBaseUrl(): string {

View File

@@ -6,7 +6,10 @@ export type MatchEndPayload = {
stats: Partial<Record<GameStat, number>> stats: Partial<Record<GameStat, number>>
} }
export type ProviderEventHandler = (event: { type: 'match_end'; payload: MatchEndPayload }) => void export type ProviderEventHandler = (event: {
type: 'match_end'
payload: MatchEndPayload
}) => void
export interface GameProvider { export interface GameProvider {
readonly id: GameId readonly id: GameId

View File

@@ -5,7 +5,6 @@ import { startGsiServer, stopGsiServer } from './gsi-server'
import { onLaunchOptionsApplied } from './steam-launch-options' import { onLaunchOptionsApplied } from './steam-launch-options'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import type { import type {
Challenge,
ChallengeResult, ChallengeResult,
GameId, GameId,
GameStatus, GameStatus,
@@ -14,6 +13,7 @@ import type {
import { STAT_LABELS } from '@shared/types' import { STAT_LABELS } from '@shared/types'
import { getChallenges, getGamesEnabled } from '../store' import { getChallenges, getGamesEnabled } from '../store'
import { fireMatchSummary } from '../notifications' import { fireMatchSummary } from '../notifications'
import { log } from '../logger'
const providers: Record<GameId, GameProvider> = { const providers: Record<GameId, GameProvider> = {
dota2: new Dota2Provider() dota2: new Dota2Provider()
@@ -21,16 +21,28 @@ const providers: Record<GameId, GameProvider> = {
let running = false let running = false
async function onMatchEnd(gameId: GameId, payload: MatchEndPayload): Promise<void> { async function onMatchEnd(
gameId: GameId,
payload: MatchEndPayload
): Promise<void> {
const provider = providers[gameId] const provider = providers[gameId]
const challenges = getChallenges().filter( const allChallenges = getChallenges().filter((c) => c.gameId === gameId)
(c) => c.gameId === gameId && c.enabled const enabledChallenges = allChallenges.filter((c) => c.enabled)
log.info(
`[games] match_end gameId=${gameId} stats=${JSON.stringify(
payload.stats
)} challenges=${enabledChallenges.length}/${allChallenges.length} (enabled/total)`
) )
const results: ChallengeResult[] = [] const results: ChallengeResult[] = []
for (const ch of challenges) { for (const ch of enabledChallenges) {
const statValue = payload.stats[ch.stat] ?? 0 const statValue = payload.stats[ch.stat] ?? 0
const reps = Math.round(statValue * ch.multiplier) const reps = Math.round(statValue * ch.multiplier)
if (reps <= 0) continue if (reps <= 0) {
log.debug(
`[games] skip challenge "${ch.name}": ${ch.stat}=${statValue} × ${ch.multiplier} = ${reps}`
)
continue
}
results.push({ results.push({
challengeId: ch.id, challengeId: ch.id,
name: ch.name, name: ch.name,
@@ -42,7 +54,21 @@ async function onMatchEnd(gameId: GameId, payload: MatchEndPayload): Promise<voi
stat: ch.stat stat: ch.stat
}) })
} }
if (results.length === 0) return if (results.length === 0) {
log.warn(
`[games] match_end produced no reps (no enabled challenges matched stats). ` +
`Enabled challenges: ${enabledChallenges.length}, stats keys: ${Object.keys(
payload.stats
).join(',')}`
)
return
}
log.info(
`[games] firing match summary: ${results.length} challenges, total reps ${results.reduce(
(s, r) => s + r.reps,
0
)}`
)
const summary: MatchSummary = { const summary: MatchSummary = {
gameId, gameId,
@@ -59,8 +85,9 @@ export async function startGamesRegistry(): Promise<void> {
running = true running = true
try { try {
await startGsiServer() await startGsiServer()
log.info('[games] GSI server started on port 4701')
} catch (err) { } catch (err) {
console.error('GSI server failed to start:', err) log.error('[games] GSI server failed to start', err)
return return
} }
@@ -77,7 +104,7 @@ export async function startGamesRegistry(): Promise<void> {
try { try {
await provider.reconcile?.() await provider.reconcile?.()
} catch (err) { } catch (err) {
console.error('reconcile failed for', id, err) log.error(`[games] reconcile failed for ${id}`, err)
} }
if (!enabled[id]) continue if (!enabled[id]) continue
await provider.start((e) => { await provider.start((e) => {
@@ -136,7 +163,10 @@ export function broadcastGames(games: GameStatus[]): void {
} }
// Simulate a match-end for debugging (called from IPC in dev). // Simulate a match-end for debugging (called from IPC in dev).
export function simulateMatchEnd(id: GameId, stats: Partial<Record<string, number>>): void { export function simulateMatchEnd(
id: GameId,
stats: Partial<Record<string, number>>
): void {
void onMatchEnd(id, { void onMatchEnd(id, {
durationMs: (stats.duration_min ?? 35) * 60_000, durationMs: (stats.duration_min ?? 35) * 60_000,
won: stats.won === 1, won: stats.won === 1,

View File

@@ -4,6 +4,7 @@ import {
existsSync, existsSync,
readFileSync, readFileSync,
readdirSync, readdirSync,
renameSync,
writeFileSync writeFileSync
} from 'node:fs' } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
@@ -53,7 +54,10 @@ function findKey(node: VdfNode, target: string): string | undefined {
return undefined return undefined
} }
function findCaseInsensitive(node: VdfNode, ...keys: string[]): VdfNode | undefined { function findCaseInsensitive(
node: VdfNode,
...keys: string[]
): VdfNode | undefined {
let cur: VdfNode = node let cur: VdfNode = node
for (const key of keys) { for (const key of keys) {
const found: string | undefined = findKey(cur, key) const found: string | undefined = findKey(cur, key)
@@ -80,7 +84,11 @@ function findOrCreatePath(node: VdfNode, ...keys: string[]): VdfNode {
return cur return cur
} }
function getAppNode(parsed: VdfNode, appId: string, create: boolean): VdfNode | undefined { function getAppNode(
parsed: VdfNode,
appId: string,
create: boolean
): VdfNode | undefined {
if (create) { if (create) {
const apps = findOrCreatePath( const apps = findOrCreatePath(
parsed, parsed,
@@ -115,21 +123,35 @@ function writeBackup(path: string): void {
} }
} }
function atomicWrite(path: string, contents: string): void { async function atomicWrite(path: string, contents: string): Promise<void> {
// Write to temp then rename (atomic on Windows for same directory). // Write to temp then rename (atomic on Windows for same directory). Retry a
// few times on transient EBUSY/EPERM (AV scanners and OneDrive sometimes
// hold a handle briefly during a Steam config rewrite).
//
// Раньше тут был busy-loop sleep — Steam-конфиги пишутся редко, но из main
// process, и при попадании на занятый файл (Steam ещё держит handle) морозили
// весь UI на 250мс. Заменили на async setTimeout-sleep.
const tmp = path + '.exr.tmp' const tmp = path + '.exr.tmp'
writeFileSync(tmp, contents, 'utf-8') const delays = [0, 50, 200]
// fs.renameSync replaces destination atomically on Windows let lastErr: unknown
// eslint-disable-next-line @typescript-eslint/no-var-requires for (const delay of delays) {
const fs = require('node:fs') as typeof import('node:fs') if (delay > 0) await new Promise<void>((r) => setTimeout(r, delay))
fs.renameSync(tmp, path) try {
writeFileSync(tmp, contents, 'utf-8')
renameSync(tmp, path)
return
} catch (e) {
lastErr = e
}
}
throw lastErr
} }
function modifyLaunchOptions( async function modifyLaunchOptions(
configPath: string, configPath: string,
appId: string, appId: string,
fn: (current: string) => string | null fn: (current: string) => string | null
): boolean { ): Promise<boolean> {
let raw: string let raw: string
try { try {
raw = readFileSync(configPath, 'utf-8') raw = readFileSync(configPath, 'utf-8')
@@ -165,7 +187,7 @@ function modifyLaunchOptions(
writeBackup(configPath) writeBackup(configPath)
try { try {
atomicWrite(configPath, stringifyVdf(parsed)) await atomicWrite(configPath, stringifyVdf(parsed))
} catch { } catch {
return false return false
} }
@@ -183,7 +205,9 @@ export async function isLaunchOptionPresent(
const parsed = parseVdf(raw) const parsed = parseVdf(raw)
const app = getAppNode(parsed, appId, false) const app = getAppNode(parsed, appId, false)
if (!app) continue if (!app) continue
const loKey = Object.keys(app).find((k) => k.toLowerCase() === 'launchoptions') const loKey = Object.keys(app).find(
(k) => k.toLowerCase() === 'launchoptions'
)
if (!loKey) continue if (!loKey) continue
const value = String(app[loKey] ?? '') const value = String(app[loKey] ?? '')
if (value.includes(option)) return true if (value.includes(option)) return true
@@ -194,20 +218,26 @@ export async function isLaunchOptionPresent(
return false return false
} }
async function applyOptionToAllConfigs(appId: string, option: string): Promise<void> { async function applyOptionToAllConfigs(
appId: string,
option: string
): Promise<void> {
const paths = await getLocalConfigPaths() const paths = await getLocalConfigPaths()
for (const p of paths) { for (const p of paths) {
modifyLaunchOptions(p, appId, (current) => { await modifyLaunchOptions(p, appId, (current) => {
if (current.includes(option)) return current if (current.includes(option)) return current
return current.length > 0 ? `${current} ${option}` : option return current.length > 0 ? `${current} ${option}` : option
}) })
} }
} }
async function removeOptionFromAllConfigs(appId: string, option: string): Promise<void> { async function removeOptionFromAllConfigs(
appId: string,
option: string
): Promise<void> {
const paths = await getLocalConfigPaths() const paths = await getLocalConfigPaths()
for (const p of paths) { for (const p of paths) {
modifyLaunchOptions(p, appId, (current) => { await modifyLaunchOptions(p, appId, (current) => {
if (!current.includes(option)) return current if (!current.includes(option)) return current
return current return current
.split(/\s+/) .split(/\s+/)

View File

@@ -6,12 +6,14 @@ import { parseVdf, type VdfNode } from './vdf'
const execAsync = promisify(exec) const execAsync = promisify(exec)
async function regQuery(key: string, valueName: string): Promise<string | undefined> { async function regQuery(
key: string,
valueName: string
): Promise<string | undefined> {
try { try {
const { stdout } = await execAsync( const { stdout } = await execAsync(`reg query "${key}" /v ${valueName}`, {
`reg query "${key}" /v ${valueName}`, windowsHide: true
{ windowsHide: true } })
)
const m = stdout.match(/REG_SZ\s+(.+)/) const m = stdout.match(/REG_SZ\s+(.+)/)
return m?.[1]?.trim() return m?.[1]?.trim()
} catch { } catch {

View File

@@ -4,7 +4,10 @@
export type VdfNode = { [key: string]: string | VdfNode } export type VdfNode = { [key: string]: string | VdfNode }
class Cursor { class Cursor {
constructor(public src: string, public pos: number = 0) {} constructor(
public src: string,
public pos: number = 0
) {}
peek(): string { peek(): string {
return this.src[this.pos] ?? '' return this.src[this.pos] ?? ''
} }
@@ -51,7 +54,12 @@ function readToken(c: Cursor): string {
} }
if (c.peek() === '{' || c.peek() === '}') return c.next() if (c.peek() === '{' || c.peek() === '}') return c.next()
let out = '' let out = ''
while (!c.eof() && !/\s/.test(c.peek()) && c.peek() !== '{' && c.peek() !== '}') { while (
!c.eof() &&
!/\s/.test(c.peek()) &&
c.peek() !== '{' &&
c.peek() !== '}'
) {
out += c.next() out += c.next()
} }
return out return out

View File

@@ -1,5 +1,9 @@
import { app, BrowserWindow, nativeTheme, systemPreferences } from 'electron' import { app, BrowserWindow, nativeTheme, systemPreferences } from 'electron'
import { createMainWindow, createReminderWindow, showMainWindow } from './windows' import {
createMainWindow,
createReminderWindow,
showMainWindow
} from './windows'
import { registerIpc } from './ipc' import { registerIpc } from './ipc'
import { startScheduler, stopScheduler } from './scheduler' import { startScheduler, stopScheduler } from './scheduler'
import { createTray } from './tray' import { createTray } from './tray'
@@ -9,9 +13,26 @@ import { broadcastState } from './state-actions'
import { startGamesRegistry, stopGamesRegistry } from './games/registry' import { startGamesRegistry, stopGamesRegistry } from './games/registry'
import { initUpdater, stopUpdater } from './updater' import { initUpdater, stopUpdater } from './updater'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import { log } from './logger'
const APP_ID = 'com.anril.exercise-reminder' const APP_ID = 'com.anril.exercise-reminder'
// Глобальная сеть безопасности: без этих обработчиков необработанное
// исключение/rejection в main-процессе валит приложение молча — пользователь
// видит, что окно просто исчезло, а в логах пусто. Логируем всё в latest.log.
// uncaughtException дополнительно флашит state, чтобы не потерять данные.
process.on('uncaughtException', (err) => {
log.error('[fatal] uncaughtException', err)
try {
flushNow()
} catch {
// flush сам может бросить (диск/AV) — мы уже в аварийном пути, глушим.
}
})
process.on('unhandledRejection', (reason) => {
log.error('[fatal] unhandledRejection', reason)
})
// Must be set BEFORE app.whenReady() for Windows toasts to show // Must be set BEFORE app.whenReady() for Windows toasts to show
// the correct app name / icon in Action Center. // the correct app name / icon in Action Center.
app.setAppUserModelId(APP_ID) app.setAppUserModelId(APP_ID)
@@ -27,15 +48,14 @@ if (!gotLock) {
registerIpc() registerIpc()
createTray() createTray()
const hidden = const hidden = wasStartedHidden() || getState().settings.startMinimized
wasStartedHidden() || getState().settings.startMinimized
createMainWindow(!hidden) createMainWindow(!hidden)
// Pre-create the reminder window so first-trigger is instant (no load lag). // Pre-create the reminder window so first-trigger is instant (no load lag).
createReminderWindow() createReminderWindow()
startScheduler() startScheduler()
startGamesRegistry().catch((err) => startGamesRegistry().catch((err) =>
console.error('games registry failed:', err) log.error('[index] games registry failed', err)
) )
initUpdater() initUpdater()
@@ -51,7 +71,8 @@ if (!gotLock) {
try { try {
const color = '#' + systemPreferences.getAccentColor() const color = '#' + systemPreferences.getAccentColor()
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtAccentChanged, color) if (!win.isDestroyed())
win.webContents.send(IPC.evtAccentChanged, color)
} }
} catch { } catch {
// ignore // ignore
@@ -69,11 +90,26 @@ if (!gotLock) {
} }
}) })
app.on('before-quit', () => { // Перехватываем первый before-quit, чтобы дождаться `stopGamesRegistry`
// (закрывает GSI HTTP server со всеми pending connections). Без этого
// следующий запуск получает EADDRINUSE на port 4701 (TIME_WAIT), и
// GSI молча не работает. После cleanup'а — реально quit.
let quitting = false
app.on('before-quit', (e) => {
if (quitting) return
e.preventDefault()
quitting = true
stopScheduler() stopScheduler()
stopUpdater() stopUpdater()
void stopGamesRegistry() void (async () => {
flushNow() try {
await stopGamesRegistry()
} catch (err) {
log.error('[index] stopGamesRegistry threw', err)
}
flushNow()
app.exit(0)
})()
}) })
app.on('activate', () => { app.on('activate', () => {

View File

@@ -1,14 +1,28 @@
import { ipcMain, nativeTheme, systemPreferences, BrowserWindow, app, shell } from 'electron' import {
ipcMain,
nativeTheme,
systemPreferences,
BrowserWindow,
app,
dialog,
shell
} from 'electron'
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
import { readFileSync, writeFileSync } from 'node:fs'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import type { Challenge, Exercise, GameId, Settings } from '@shared/types' import type { Exercise, GameId, Settings } from '@shared/types'
import { import {
addChallenge, addChallenge,
addExercise, addExercise,
clearHistory, clearHistory,
deleteChallenge, deleteChallenge,
deleteExercise, deleteExercise,
exportState,
getHistory, getHistory,
getState, getState,
getStateForRenderer,
importState,
markChallengeDone,
markDone, markDone,
setGameEnabled, setGameEnabled,
skip, skip,
@@ -17,10 +31,11 @@ import {
updateExercise, updateExercise,
updateSettings updateSettings
} from './store' } from './store'
import { broadcastState } from './state-actions' import { broadcastHistoryChanged, broadcastState } from './state-actions'
import { setAutostart, isAutostartEnabled } from './autostart' import { setAutostart, isAutostartEnabled } from './autostart'
import { setPaused, forceCheck } from './scheduler' import { forceCheck } from './scheduler'
import { hideReminderWindow, getMainWindow } from './windows' import { hideReminderWindow, getMainWindow } from './windows'
import { refreshMenu } from './tray'
import { import {
broadcastGames, broadcastGames,
installGame, installGame,
@@ -35,68 +50,153 @@ import {
getUpdaterStatus, getUpdaterStatus,
quitAndInstall quitAndInstall
} from './updater' } from './updater'
import { isMeetingActiveSync } from './meeting-detect'
import {
validateActualReps,
validateChallengeInput,
validateChallengePatch,
validateExerciseInput,
validateExercisePatch,
validateId,
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 { export function registerIpc(): void {
ipcMain.handle(IPC.getState, () => { safeHandle(IPC.getState, () => {
const state = getState() // Без history (см. getStateForRenderer) и с актуальным значением
state.settings.startWithWindows = isAutostartEnabled() // autostart из OS — мутацию делаем по копии, не по cache.
const state = getStateForRenderer()
state.settings = {
...state.settings,
startWithWindows: isAutostartEnabled()
}
return state return state
}) })
ipcMain.handle( safeHandle(IPC.addExercise, (_e, input: unknown) => {
IPC.addExercise, const safe = validateExerciseInput(input)
(_e, input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>) => { if (!safe) return null
const ex = addExercise(input) const ex = addExercise(safe)
broadcastState()
return ex
})
safeHandle(
IPC.updateExercise,
(_e, idRaw: unknown, patchRaw: unknown) => {
const id = validateId(idRaw)
const patch = validateExercisePatch(patchRaw)
if (!id || !patch) return null
const ex = updateExercise(id, patch)
broadcastState() broadcastState()
return ex return ex
} }
) )
ipcMain.handle(IPC.updateExercise, (_e, id: string, patch: Partial<Exercise>) => { safeHandle(IPC.deleteExercise, (_e, idRaw: unknown) => {
const ex = updateExercise(id, patch) const id = validateId(idRaw)
broadcastState() if (!id) return false
return ex
})
ipcMain.handle(IPC.deleteExercise, (_e, id: string) => {
const ok = deleteExercise(id) const ok = deleteExercise(id)
broadcastState() broadcastState()
return ok return ok
}) })
ipcMain.handle(IPC.toggleExercise, (_e, id: string, enabled: boolean) => { safeHandle(
const patch: Partial<Exercise> = { enabled } IPC.toggleExercise,
if (enabled) { (_e, idRaw: unknown, enabledRaw: unknown) => {
const ex = getState().exercises.find((e) => e.id === id) const id = validateId(idRaw)
if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 if (!id || typeof enabledRaw !== 'boolean') return null
} const patch: Partial<Exercise> = { enabled: enabledRaw }
const ex = updateExercise(id, patch) if (enabledRaw) {
broadcastState() const ex = getState().exercises.find((e) => e.id === id)
return ex if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
}) }
const ex = updateExercise(id, patch)
ipcMain.handle(
IPC.markDone,
(_e, id: string, actualReps?: number) => {
const ex = markDone(id, actualReps)
broadcastState() broadcastState()
return ex return ex
} }
) )
ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => { 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
})
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) const ex = snooze(id, minutes)
broadcastState() broadcastState()
broadcastHistoryChanged()
return ex return ex
}) })
ipcMain.handle(IPC.skip, (_e, id: string) => { safeHandle(IPC.skip, (_e, idRaw: unknown) => {
const id = validateId(idRaw)
if (!id) return null
const ex = skip(id) const ex = skip(id)
broadcastState() broadcastState()
broadcastHistoryChanged()
return ex return ex
}) })
ipcMain.handle(IPC.updateSettings, (_e, patch: Partial<Settings>) => { safeHandle(IPC.updateSettings, (_e, patchRaw: unknown) => {
const patch = validateSettingsPatch(patchRaw)
if (!patch) return null
if (patch.startWithWindows !== undefined) { if (patch.startWithWindows !== undefined) {
setAutostart(patch.startWithWindows) setAutostart(patch.startWithWindows)
} }
@@ -106,10 +206,27 @@ export function registerIpc(): void {
} }
const settings = updateSettings(merged) const settings = updateSettings(merged)
broadcastState() broadcastState()
// Tray-menu label «Пауза/Возобновить» зависит от globalEnabled — рефреш.
// А также language change.
if (patch.language !== undefined || patch.globalEnabled !== undefined) {
refreshMenu()
}
return settings return settings
}) })
ipcMain.handle(IPC.getAccentColor, () => { safeHandle(IPC.pauseAll, () => {
updateSettings({ globalEnabled: false })
broadcastState()
refreshMenu()
})
safeHandle(IPC.resumeAll, () => {
updateSettings({ globalEnabled: true })
broadcastState()
forceCheck()
refreshMenu()
})
safeHandle(IPC.getAccentColor, () => {
try { try {
return '#' + systemPreferences.getAccentColor() return '#' + systemPreferences.getAccentColor()
} catch { } catch {
@@ -117,36 +234,45 @@ export function registerIpc(): void {
} }
}) })
ipcMain.handle(IPC.getOsTheme, () => safeHandle(IPC.getOsTheme, () =>
nativeTheme.shouldUseDarkColors ? 'dark' : 'light' nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
) )
ipcMain.handle(IPC.pauseAll, () => setPaused(true)) safeHandle(IPC.getAppVersion, () => app.getVersion())
ipcMain.handle(IPC.resumeAll, () => {
setPaused(false)
forceCheck()
})
ipcMain.handle(IPC.quit, () => app.quit()) safeHandle(IPC.getMeetingActive, () => isMeetingActiveSync())
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
ipcMain.on(IPC.minimizeMain, (event) => { safeHandle(IPC.quit, () => app.quit())
safeHandle(IPC.reminderClose, () => hideReminderWindow())
safeOn(IPC.minimizeMain, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize() BrowserWindow.fromWebContents(event.sender)?.minimize()
}) })
ipcMain.on(IPC.closeMain, () => { safeOn(IPC.toggleMaximizeMain, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
if (win.isMaximized()) win.unmaximize()
else win.maximize()
})
safeHandle(IPC.isMaximizedMain, (event) => {
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false
})
safeOn(IPC.closeMain, () => {
const main = getMainWindow() const main = getMainWindow()
if (!main) return if (!main) return
if (getState().settings.minimizeToTray) main.hide() if (getState().settings.minimizeToTray) main.hide()
else main.close() else main.close()
}) })
ipcMain.on(IPC.hideMain, () => getMainWindow()?.hide()) safeOn(IPC.hideMain, () => getMainWindow()?.hide())
// Games // Games
ipcMain.handle(IPC.gamesList, async () => listGamesStatus()) safeHandle(IPC.gamesList, async () => listGamesStatus())
ipcMain.handle(IPC.gameInstall, async (_e, id: GameId) => { safeHandle(IPC.gameInstall, async (_e, id: GameId) => {
const status = await installGame(id) const status = await installGame(id)
setGameEnabled(id, true) setGameEnabled(id, true)
await toggleGame(id, true) await toggleGame(id, true)
@@ -156,7 +282,7 @@ export function registerIpc(): void {
return status return status
}) })
ipcMain.handle(IPC.gameUninstall, async (_e, id: GameId) => { safeHandle(IPC.gameUninstall, async (_e, id: GameId) => {
const status = await uninstallGame(id) const status = await uninstallGame(id)
setGameEnabled(id, false) setGameEnabled(id, false)
const all = await listGamesStatus() const all = await listGamesStatus()
@@ -165,7 +291,7 @@ export function registerIpc(): void {
return status return status
}) })
ipcMain.handle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => { safeHandle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
setGameEnabled(id, enabled) setGameEnabled(id, enabled)
await toggleGame(id, enabled) await toggleGame(id, enabled)
const all = await listGamesStatus() const all = await listGamesStatus()
@@ -173,55 +299,147 @@ export function registerIpc(): void {
broadcastState() broadcastState()
}) })
ipcMain.handle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => { safeHandle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
// Opens Steam's library; user manually adds launch options. // Opens Steam's library; user manually adds launch options.
shell.openExternal('steam://nav/games/details/570') shell.openExternal('steam://nav/games/details/570')
}) })
// Challenges // Challenges
ipcMain.handle(IPC.addChallenge, (_e, input: Omit<Challenge, 'id'>) => { safeHandle(IPC.addChallenge, (_e, input: unknown) => {
const c = addChallenge(input) const safe = validateChallengeInput(input)
if (!safe) return null
const c = addChallenge(safe)
broadcastState() broadcastState()
return c return c
}) })
ipcMain.handle( safeHandle(
IPC.updateChallenge, IPC.updateChallenge,
(_e, id: string, patch: Partial<Challenge>) => { (_e, idRaw: unknown, patchRaw: unknown) => {
const id = validateId(idRaw)
const patch = validateChallengePatch(patchRaw)
if (!id || !patch) return null
const c = updateChallenge(id, patch) const c = updateChallenge(id, patch)
broadcastState() broadcastState()
return c return c
} }
) )
ipcMain.handle(IPC.deleteChallenge, (_e, id: string) => { safeHandle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
const id = validateId(idRaw)
if (!id) return false
const ok = deleteChallenge(id) const ok = deleteChallenge(id)
broadcastState() broadcastState()
return ok return ok
}) })
ipcMain.handle(IPC.toggleChallenge, (_e, id: string, enabled: boolean) => { safeHandle(
const c = updateChallenge(id, { enabled }) IPC.toggleChallenge,
broadcastState() (_e, idRaw: unknown, enabledRaw: unknown) => {
return c const id = validateId(idRaw)
}) if (!id || typeof enabledRaw !== 'boolean') return null
const c = updateChallenge(id, { enabled: enabledRaw })
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow()) broadcastState()
return c
// Dev helper: simulate a match end with given stats.
ipcMain.handle(
'dev:simulateMatchEnd',
(_e, id: GameId, stats: Record<string, number>) => {
simulateMatchEnd(id, stats)
} }
) )
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) {
safeHandle(
IPC.devSimulateMatchEnd,
(_e, id: GameId, stats: Record<string, number>) => {
simulateMatchEnd(id, stats)
}
)
}
// Auto-updater // Auto-updater
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus()) safeHandle(IPC.updaterStatus, () => getUpdaterStatus())
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates()) safeHandle(IPC.updaterCheck, () => checkForUpdates())
ipcMain.handle(IPC.updaterDownload, () => downloadUpdate()) // download/install — fire-and-forget. Прогресс и завершение приходят в
ipcMain.handle(IPC.updaterInstall, () => quitAndInstall()) // renderer через evtUpdaterStatus, ждать promise бессмысленно — renderer
// только зря держал бы `busy=true` весь download (минуты на медленной сети).
safeOn(IPC.updaterDownload, () => {
void downloadUpdate()
})
safeOn(IPC.updaterInstall, () => quitAndInstall())
// History // History
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs)) safeHandle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) => safeHandle(IPC.clearHistory, (_e, beforeTs?: number) => {
clearHistory(beforeTs) const removed = clearHistory(beforeTs)
) if (removed > 0) broadcastHistoryChanged()
return removed
})
// Export / Import. Используем native save/open dialogs Electron'а
// renderer не получает прямого доступа к ФС.
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:
lang === 'en' ? 'Save backup' : 'Сохранить резервную копию',
defaultPath,
filters: [{ name: 'JSON', extensions: ['json'] }]
})
// 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, canceled: false, path: result.filePath }
} catch (e) {
return { ok: false, canceled: false, path: null, error: String(e) }
}
})
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:
lang === 'en' ? 'Restore from backup' : 'Восстановить из резервной копии',
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, canceled: true }
}
try {
const raw = readFileSync(result.filePaths[0], 'utf-8')
const ok = importState(raw)
if (ok) {
broadcastState()
broadcastHistoryChanged()
}
return { ok, canceled: false }
} catch (e) {
return { ok: false, canceled: false, error: String(e) }
}
})
} }

125
src/main/logger.ts Normal file
View File

@@ -0,0 +1,125 @@
/* eslint-disable no-console -- этот файл — единственное место где console.*
разрешён намеренно: дублирование лога в stderr для dev-режима. */
/**
* Минимальный logger для main process.
*
* Пишет в файл `%APPDATA%/Exercise Reminder/logs/latest.log` + дублирует
* в stderr через console.* (чтобы dev-режим оставался удобным).
*
* Ротация: при достижении 1MB latest.log переименовывается в prev.log
* (предыдущий prev.log удаляется). Две сессии истории — этого достаточно
* для воспроизведения «случилось вчера, а сегодня перезапустил». Никакой
* remote-телеметрии: лог локальный, пользователь сам может вложить его в
* issue если что-то сломалось.
*
* Уровни:
* - debug: подробный traceback, видим только если LAUDE_DEBUG=1
* - info: значимые события (startup, GSI matched, updater progress)
* - warn: recoverable issues (transient network, retry succeeded)
* - error: что-то реально сломалось (atomic write fail, IPC validation)
*/
import { app } from 'electron'
import {
appendFileSync,
existsSync,
mkdirSync,
renameSync,
statSync,
unlinkSync
} from 'node:fs'
import { join } from 'node:path'
const ROTATE_AT_BYTES = 1 * 1024 * 1024 // 1 MB
type Level = 'debug' | 'info' | 'warn' | 'error'
let logDir = ''
let logPath = ''
let prevPath = ''
function ensurePaths(): void {
if (logDir) return
try {
logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
logPath = join(logDir, 'latest.log')
prevPath = join(logDir, 'prev.log')
} catch {
// app.getPath не готов (очень ранний boot) — отложим, console продолжит.
}
}
function rotateIfNeeded(): void {
if (!logPath) return
try {
if (!existsSync(logPath)) return
const size = statSync(logPath).size
if (size < ROTATE_AT_BYTES) return
if (existsSync(prevPath)) unlinkSync(prevPath)
renameSync(logPath, prevPath)
} catch {
// не критично — продолжим писать в latest.log с overflow
}
}
function ts(): string {
return new Date().toISOString()
}
function levelTag(l: Level): string {
return l.toUpperCase().padEnd(5, ' ')
}
function write(level: Level, msg: string, extra?: unknown): void {
// Always dup to console for dev. structuredClone-style serialize:
const line = `[${ts()}] ${levelTag(level)} ${msg}${
extra !== undefined ? ' ' + safeStringify(extra) : ''
}\n`
switch (level) {
case 'error':
console.error(line.trimEnd())
break
case 'warn':
console.warn(line.trimEnd())
break
case 'debug':
case 'info':
default:
console.log(line.trimEnd())
}
ensurePaths()
rotateIfNeeded()
if (!logPath) return
try {
appendFileSync(logPath, line, 'utf-8')
} catch {
// Если AV держит файл — переживём, в console уже залогировали.
}
}
function safeStringify(v: unknown): string {
if (v instanceof Error) {
return v.stack ?? `${v.name}: ${v.message}`
}
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
const DEBUG_ENABLED = process.env.LAUDE_DEBUG === '1'
export const log = {
debug: (msg: string, extra?: unknown): void => {
if (DEBUG_ENABLED) write('debug', msg, extra)
},
info: (msg: string, extra?: unknown): void => write('info', msg, extra),
warn: (msg: string, extra?: unknown): void => write('warn', msg, extra),
error: (msg: string, extra?: unknown): void => write('error', msg, extra)
}
/** Путь к логам (для диагностики). Возвращает пустую строку до initLogger(). */
export function getLogDir(): string {
return logDir
}

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

112
src/main/meeting-detect.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Эвристическое обнаружение «человек на ВКС» по списку запущенных процессов.
*
* Идея: если запущен Zoom/Teams/Discord/Meet/Webex — пользователь скорее
* всего на встрече или собирается зайти. Останавливаем напоминания, чтобы
* не прерывать. После «снятия» процессов возобновляем.
*
* Проверка раз в 30 сек через `tasklist` (CSV-режим, без окна). Не используем
* audio capture API / mic state — это потребовало бы Windows-specific
* native-модуля; задача fit-for-purpose, не security-critical.
*
* False positive vs false negative tradeoff: лучше один лишний скип
* напоминания, чем один pop-up посреди митинга. Поэтому даже если
* процесс запущен, но митинга нет (Teams в фоне) — пауза. Пользователь
* это переживёт, обратное — не очень.
*/
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)
/**
* Имена процессов (Windows .exe). Регистр игнорируется при сравнении.
* Покрываем основные VOIP-приложения для индустрии WFH.
*/
const MEETING_PROCESSES = new Set([
'zoom.exe',
'teams.exe',
'ms-teams.exe', // новые Teams 2.0
'discord.exe',
'webex.exe',
'webexmta.exe',
'meet.exe', // Google Meet desktop (редкость)
'slack.exe', // huddle
'skype.exe',
'gotomeeting.exe',
'whereby.exe'
])
let cachedActive = false
let lastCheckAt = 0
const CACHE_MS = 30_000
/**
* Проверяет, запущен ли хотя бы один из meeting-процессов. Кеширует
* результат на CACHE_MS, чтобы tasklist не дёргался каждую секунду из
* scheduler-tick'а.
*/
export async function isMeetingActive(): Promise<boolean> {
if (process.platform !== 'win32') return false
const now = Date.now()
if (now - lastCheckAt < CACHE_MS) return cachedActive
lastCheckAt = now
try {
// CSV без заголовков (/NH), скрытое окно.
const { stdout } = await execAsync('tasklist /FO CSV /NH', {
windowsHide: true,
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) {
// Простой substring-match достаточен: формат CSV-row
// "zoom.exe","12345","Console","1","85,432 K".
if (lower.includes(`"${proc}",`)) {
if (!cachedActive) {
log.info(`[meeting] detected ${proc} — pausing reminders`)
broadcast(true)
}
cachedActive = true
return true
}
}
if (cachedActive) {
log.info('[meeting] no meeting processes — resuming reminders')
broadcast(false)
}
cachedActive = false
return false
} catch (e) {
// tasklist может фейлиться на нестандартных образах Windows. Лучше
// продолжить работу как обычно (не паузить), чем глушить напоминания
// навсегда.
log.warn('[meeting] tasklist failed, assuming no meeting', e)
cachedActive = false
return false
}
}
/** Синхронный «последнее известное значение» без запроса. Используется
* в scheduler-tick'е — он не async. Background refresh идёт отдельно. */
export function isMeetingActiveSync(): boolean {
return cachedActive
}
/** Background refresh, дёргаем из scheduler'а. Не await'им. */
export function refreshMeetingState(): void {
void isMeetingActive()
}

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

@@ -1,36 +1,98 @@
import { powerMonitor, BrowserWindow } from 'electron' import { powerMonitor, BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import type { Tick } from '@shared/types' import type { Exercise, Tick, HistoryEntry } from '@shared/types'
import { isQuietAt } from '@shared/types' import { isQuietAt } from '@shared/types'
import { getExercises, getSettings, updateExercise } from './store' import { getExercises, getHistory, getSettings, updateExercise } from './store'
import { fireReminder } from './notifications' import { fireReminder } from './notifications'
import { broadcastState } from './state-actions'
import { isMeetingActiveSync, refreshMeetingState } from './meeting-detect'
import { adjustNextFireAt } from './adaptive'
/**
* Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day).
* Учитываем actualReps если задано (частичное выполнение), иначе planned reps.
*/
function repsDoneToday(ex: Exercise, history: HistoryEntry[]): number {
const todayKey = new Date()
todayKey.setHours(0, 0, 0, 0)
const startMs = todayKey.getTime()
let sum = 0
for (const e of history) {
if (e.action !== 'done') continue
if (e.exerciseId !== ex.id) continue
if (e.ts < startMs) continue
sum += e.actualReps ?? ex.reps
}
return sum
}
/**
* TICK_MS drives the per-second countdown UI; CHECK_MS gates the (cheaper)
* "is anything due to fire?" pass so we don't iterate exercises every second.
*/
const TICK_MS = 1000 const TICK_MS = 1000
const CHECK_MS = 5000 const CHECK_MS = 5000
let tickHandle: NodeJS.Timeout | null = null let tickHandle: NodeJS.Timeout | null = null
let powerListenersArmed = false
let lastCheckAt = 0 let lastCheckAt = 0
let paused = false
function checkDueExercises(): void { function checkDueExercises(): void {
if (paused) return
const settings = getSettings() const settings = getSettings()
if (!settings.globalEnabled) return if (!settings.globalEnabled) return
// Inside the quiet window: defer all due fires to the next minute boundary. // Inside the quiet window: defer all due fires until it closes. The next
// The next tick after the window closes will pick them up. // CHECK_MS pass after the window ends will pick them up.
if (isQuietAt(settings.quietHours, new Date())) return if (isQuietAt(settings.quietHours, new Date())) return
// Авто-пауза на встречах. Sync-чтение кеша (последнее значение); refresh
// запускаем в фоне чтобы кеш «зрел» к следующему tick'у. На холодном
// старте кеш false — первое срабатывание может прийти в момент митинга,
// но 30 сек спустя система догонит и больше не будет fire'ить.
if (settings.meetingAutoPause) {
refreshMeetingState()
if (isMeetingActiveSync()) return
}
const now = Date.now() const now = Date.now()
const exercises = getExercises() const exercises = getExercises()
// history запрашивается если у какого-нибудь упражнения есть
// dailyGoal или adaptive: false — иначе экономим IPC-нагрузку.
const needsHistory = exercises.some(
(e) => e.dailyGoal !== undefined || e.adaptive
)
const history = needsHistory ? getHistory() : []
let anyFired = false
for (const ex of exercises) { for (const ex of exercises) {
if (!ex.enabled) continue if (!ex.enabled) continue
if (ex.nextFireAt <= now) { if (ex.nextFireAt > now) continue
const updated = updateExercise(ex.id, { // Soft cap: если dailyGoal задан и уже выполнен — переносим
nextFireAt: now + ex.intervalMinutes * 60_000 // следующий fire на «начало завтра» (без повторных проверок до утра).
}) if (ex.dailyGoal !== undefined && ex.dailyGoal > 0) {
if (updated) fireReminder(updated, settings.notificationMode) const done = repsDoneToday(ex, history)
if (done >= ex.dailyGoal) {
const tomorrow = new Date()
tomorrow.setHours(0, 0, 0, 0)
tomorrow.setDate(tomorrow.getDate() + 1)
updateExercise(ex.id, { nextFireAt: tomorrow.getTime() })
continue
}
}
// Базовый candidate. Если adaptive — сдвигаем на «хороший» час
// по исторической статистике успеха/скипов.
let nextFireAt = now + ex.intervalMinutes * 60_000
if (ex.adaptive) {
nextFireAt = adjustNextFireAt(ex, nextFireAt, history)
}
const updated = updateExercise(ex.id, { nextFireAt })
if (updated) {
anyFired = true
fireReminder(updated, settings.notificationMode)
} }
} }
// Push fresh state so the renderer's Dashboard/Exercises pages don't show
// stale `nextFireAt` until the next state-changing IPC arrives.
if (anyFired) broadcastState()
} }
function broadcastTicks(): void { function broadcastTicks(): void {
@@ -61,14 +123,20 @@ export function startScheduler(): void {
// Run an immediate tick so renderer hydrates quickly. // Run an immediate tick so renderer hydrates quickly.
tick() tick()
powerMonitor.on('resume', () => { // Only attach powerMonitor listeners once per process — startScheduler may
lastCheckAt = 0 // be invoked again after stopScheduler in dev hot-reload paths and we don't
tick() // want the same handler firing N times after a resume.
}) if (!powerListenersArmed) {
powerMonitor.on('unlock-screen', () => { powerListenersArmed = true
lastCheckAt = 0 powerMonitor.on('resume', () => {
tick() lastCheckAt = 0
}) tick()
})
powerMonitor.on('unlock-screen', () => {
lastCheckAt = 0
tick()
})
}
} }
export function stopScheduler(): void { export function stopScheduler(): void {
@@ -78,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 { export function forceCheck(): void {
lastCheckAt = 0 lastCheckAt = 0
tick() tick()

View File

@@ -1,14 +1,24 @@
import { BrowserWindow } from 'electron' import { BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import { getExercises, getState, updateExercise } from './store' import { getExercises, getStateForRenderer, updateExercise } from './store'
export function broadcastState(): void { export function broadcastState(): void {
const state = getState() // Используем variant без `history` — иначе при 10k записей через IPC
// на каждый markDone летит 500KB JSON × M подписчиков. Renderer
// запрашивает историю отдельно через IPC.getHistory.
const state = getStateForRenderer()
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtStateChanged, state) if (!win.isDestroyed()) win.webContents.send(IPC.evtStateChanged, state)
} }
} }
/** Сигнализирует renderer'у что историю надо перетянуть. */
export function broadcastHistoryChanged(): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtHistoryChanged)
}
}
export function snoozeAll(minutes: number): void { export function snoozeAll(minutes: number): void {
const now = Date.now() const now = Date.now()
for (const ex of getExercises()) { for (const ex of getExercises()) {

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

@@ -1,5 +1,12 @@
import { app } from 'electron' import { app } from 'electron'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { import {
@@ -10,14 +17,22 @@ import {
GameId, GameId,
HistoryAction, HistoryAction,
HistoryEntry, HistoryEntry,
PersistedState,
SAMPLE_EXERCISES, SAMPLE_EXERCISES,
Settings Settings
} from '@shared/types' } from '@shared/types'
import { log } from './logger'
/** Keep at most this many entries (~3 years if ~10/day). Trim oldest. */ /**
* Keep at most this many history entries (≈2.7 years at 10/day).
* When the cap is hit, drop oldest 10% so we don't trim on every write.
*/
const HISTORY_MAX = 10_000 const HISTORY_MAX = 10_000
let cache: AppState | null = null const WRITE_DEBOUNCE_MS = 1500
const WRITE_RETRY_DELAYS = [50, 200, 800] // ms backoff on transient EBUSY/EPERM
let cache: PersistedState | null = null
let storePath = '' let storePath = ''
let pendingWrite: NodeJS.Timeout | null = null let pendingWrite: NodeJS.Timeout | null = null
@@ -30,7 +45,7 @@ function getStorePath(): string {
return storePath return storePath
} }
function makeInitial(): AppState { function makeInitial(): PersistedState {
const now = Date.now() const now = Date.now()
return { return {
exercises: SAMPLE_EXERCISES.map((e) => ({ exercises: SAMPLE_EXERCISES.map((e) => ({
@@ -66,43 +81,150 @@ function makeInitial(): AppState {
} }
} }
function load(): AppState { /** Quarantine a corrupt state file so the user can recover it manually. */
function quarantineCorrupt(p: string, reason: string): void {
try {
const stamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.replace(/Z$/, '')
const dest = `${p}.corrupt-${stamp}`
renameSync(p, dest)
log.error(
`[store] app-state.json was unreadable (${reason}); moved to ${dest} and starting fresh.`
)
} catch (e) {
log.error('[store] failed to quarantine corrupt state file', e)
}
}
function isValidParsed(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
/**
* Current persisted-state schema version. Bump this and add a migration to
* MIGRATIONS whenever the on-disk shape changes in a non-additive way.
*
* Additive changes (new optional fields, new entries in `gamesEnabled`) do
* NOT need a version bump — DEFAULT_SETTINGS spread + the `?? []` guards in
* `coerce()` handle them gracefully.
*/
const CURRENT_SCHEMA_VERSION = 1
type StoredState = Record<string, unknown> & { __schemaVersion?: number }
/**
* Migrations are applied in order until the stored version matches CURRENT.
* Each fn returns the next-version state. The receiver may freely mutate.
*
* Note: the v0→v1 migration is a no-op — v1 is the inaugural schema. The
* machinery exists so future structural changes (e.g. splitting
* `quietHours.days` into a per-window record) have a single explicit place
* to live.
*/
const MIGRATIONS: Record<number, (s: StoredState) => StoredState> = {
0: (s) => s
}
function runMigrations(s: StoredState): StoredState {
let version = typeof s.__schemaVersion === 'number' ? s.__schemaVersion : 0
let cursor = s
while (version < CURRENT_SCHEMA_VERSION) {
const fn = MIGRATIONS[version]
if (!fn) {
console.warn(
`[store] no migration from v${version}; skipping ahead and hoping for the best.`
)
break
}
cursor = fn(cursor)
version += 1
}
cursor.__schemaVersion = CURRENT_SCHEMA_VERSION
return cursor
}
/** Coerce a (possibly partial) migrated state into a fully-formed PersistedState. */
function coerce(s: StoredState): PersistedState {
return {
exercises: Array.isArray(s.exercises) ? (s.exercises as Exercise[]) : [],
settings: {
...DEFAULT_SETTINGS,
...(isValidParsed(s.settings) ? (s.settings as Partial<Settings>) : {})
},
challenges: Array.isArray(s.challenges)
? (s.challenges as Challenge[])
: [],
gamesEnabled: isValidParsed(s.gamesEnabled)
? (s.gamesEnabled as Partial<Record<GameId, boolean>>)
: {},
history: Array.isArray(s.history) ? (s.history as HistoryEntry[]) : []
}
}
function load(): PersistedState {
const p = getStorePath() const p = getStorePath()
if (!existsSync(p)) { if (!existsSync(p)) {
const initial = makeInitial() const initial = makeInitial()
writeFileSync(p, JSON.stringify(initial, null, 2), 'utf-8') // Cold path — sync write на инициализации (event-loop ещё не активен).
atomicWriteSync(
p,
JSON.stringify(
{ __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial },
null,
2
)
)
return initial return initial
} }
let raw: string
try { try {
const raw = readFileSync(p, 'utf-8') raw = readFileSync(p, 'utf-8')
const parsed = JSON.parse(raw) as Partial<AppState> } catch (e) {
return { log.error('[store] cannot read state file', e)
exercises: parsed.exercises ?? [], return makeInitial() // do not quarantine — we can't read it anyway
settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) }, }
challenges: parsed.challenges ?? [], let parsed: unknown
gamesEnabled: parsed.gamesEnabled ?? {}, try {
history: parsed.history ?? [] parsed = JSON.parse(raw)
} } catch (e) {
} catch { quarantineCorrupt(p, `JSON parse error: ${String(e)}`)
return makeInitial() return makeInitial()
} }
if (!isValidParsed(parsed)) {
quarantineCorrupt(p, `expected object, got ${typeof parsed}`)
return makeInitial()
}
return coerce(runMigrations(parsed))
}
type AppendOpts = {
actualReps?: number
/** Planned reps snapshot — иначе после удаления упражнения теряем reps. */
reps?: number
/** Snapshot названия — для будущего log-view (необязательно). */
name?: string
/** 'reminder' (default) или 'match'. */
source?: import('@shared/types').HistorySource
} }
function appendHistory( function appendHistory(
exerciseId: string, exerciseId: string,
action: HistoryAction, action: HistoryAction,
actualReps?: number opts: AppendOpts = {}
): void { ): void {
const state = getState() const state = getState()
if (!state.history) state.history = [] if (!state.history) state.history = []
const entry: HistoryEntry = { ts: Date.now(), exerciseId, action } const entry: HistoryEntry = { ts: Date.now(), exerciseId, action }
if (actualReps !== undefined) entry.actualReps = actualReps if (opts.actualReps !== undefined) entry.actualReps = opts.actualReps
if (opts.reps !== undefined) entry.reps = opts.reps
if (opts.name !== undefined) entry.name = opts.name
if (opts.source !== undefined) entry.source = opts.source
state.history.push(entry) state.history.push(entry)
// Cap size — trim oldest 10% when over limit, so we don't trim every write.
if (state.history.length > HISTORY_MAX) { if (state.history.length > HISTORY_MAX) {
state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9)) state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9))
} }
scheduleWrite()
} }
export function getHistory(sinceMs?: number): HistoryEntry[] { export function getHistory(sinceMs?: number): HistoryEntry[] {
@@ -115,32 +237,135 @@ export function clearHistory(beforeTs?: number): number {
const state = getState() const state = getState()
const before = state.history?.length ?? 0 const before = state.history?.length ?? 0
if (beforeTs == null) { if (beforeTs == null) {
state.history = [] // Refuse a full wipe via IPC — callers must pass an explicit boundary.
} else { // (Settings UI passes 0 to wipe everything; that's an opt-in.)
state.history = (state.history ?? []).filter((e) => e.ts >= beforeTs) return 0
} }
state.history = (state.history ?? []).filter((e) => e.ts >= beforeTs)
scheduleWrite() scheduleWrite()
return before - (state.history?.length ?? 0) return before - (state.history?.length ?? 0)
} }
function flush(): void { /**
* Atomically write to `path` via a sibling .tmp file + rename. Retries a few
* times on transient EBUSY/EPERM (AV/OneDrive holding the file).
*
* Async version (используется debounced scheduleWrite/flush) — раньше был
* busy-loop `while (Date.now() < until)`, который морозил весь main process
* на retry-delay (до 800мс). При активном AV это превращалось в видимое
* залипание UI. Сейчас sleep через setTimeout-promise.
*
* Для процесса-выхода используется `atomicWriteSync` — там event-loop уже
* не работает, async sleep не сработает.
*/
async function atomicWrite(path: string, contents: string): Promise<void> {
const tmp = `${path}.tmp`
let lastErr: unknown
for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) {
try {
writeFileSync(tmp, contents, 'utf-8')
renameSync(tmp, path)
return
} catch (e) {
lastErr = e
try {
if (existsSync(tmp)) unlinkSync(tmp)
} catch {
/* ignore */
}
const delay = WRITE_RETRY_DELAYS[i]
if (delay === undefined) break
await new Promise<void>((r) => setTimeout(r, delay))
}
}
log.error('[store] atomic write failed after retries', lastErr)
}
/**
* Синхронный вариант для use-cases где event loop уже не работает
* (process exit в `before-quit`). При retry — короткий sync sleep, потому
* что иначе мы дропнем pending write при exit'е.
*/
function atomicWriteSync(path: string, contents: string): void {
const tmp = `${path}.tmp`
let lastErr: unknown
for (let i = 0; i <= WRITE_RETRY_DELAYS.length; i++) {
try {
writeFileSync(tmp, contents, 'utf-8')
renameSync(tmp, path)
return
} catch (e) {
lastErr = e
try {
if (existsSync(tmp)) unlinkSync(tmp)
} catch {
/* ignore */
}
const delay = WRITE_RETRY_DELAYS[i]
if (delay === undefined) break
// 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)
}
async function flush(): Promise<void> {
if (!cache) return if (!cache) return
writeFileSync(getStorePath(), JSON.stringify(cache, null, 2), 'utf-8') // Persist the schema version alongside the state so future migrations know
// where to pick up from. The renderer never reads this key.
const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache }
await atomicWrite(getStorePath(), JSON.stringify(payload, null, 2))
}
function flushSync(): void {
if (!cache) return
const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache }
atomicWriteSync(getStorePath(), JSON.stringify(payload, null, 2))
} }
function scheduleWrite(): void { function scheduleWrite(): void {
if (pendingWrite) return if (pendingWrite) return
pendingWrite = setTimeout(() => { pendingWrite = setTimeout(() => {
pendingWrite = null pendingWrite = null
flush() void flush()
}, 1500) }, WRITE_DEBOUNCE_MS)
// Don't keep the event loop alive solely for a pending write — `before-quit`
// calls `flushNow()` and we explicitly want the process to exit on schedule.
pendingWrite.unref?.()
} }
export function getState(): AppState { /**
* Internal persisted state — единственный source of truth. Включает историю.
* Mutate напрямую (mutations внутри store.ts), затем scheduleWrite().
*/
export function getState(): PersistedState {
if (!cache) cache = load() if (!cache) cache = load()
return cache return cache
} }
/**
* State для отправки renderer'у. Копия БЕЗ `history` — историю renderer
* запрашивает отдельным IPC.getHistory. Раньше каждый markDone/snooze
* отправлял весь state через evtStateChanged, и при 10k entries в истории
* это 500KB JSON × N IPC mutations подряд → заметный лаг.
*
* Возвращаемая копия безопасна для мутации (ipc.ts накладывает на settings
* актуальное OS-значение startWithWindows) — мы НЕ мутируем cache.
*/
export function getStateForRenderer(): AppState {
const p = getState()
return {
exercises: p.exercises,
settings: p.settings,
challenges: p.challenges,
gamesEnabled: p.gamesEnabled
}
}
export function getSettings(): Settings { export function getSettings(): Settings {
return getState().settings return getState().settings
} }
@@ -178,9 +403,15 @@ export function updateExercise(
const idx = state.exercises.findIndex((e) => e.id === id) const idx = state.exercises.findIndex((e) => e.id === id)
if (idx === -1) return undefined if (idx === -1) return undefined
const prev = state.exercises[idx] const prev = state.exercises[idx]
const merged: Exercise = { ...prev, ...patch } // Drop `id` from the patch even though the type forbids it — runtime callers
// (IPC) can still smuggle it through. We never let the id change.
const { id: _ignoredId, ...safePatch } = patch as Partial<Exercise>
const merged: Exercise = { ...prev, ...safePatch }
// If interval changed, reschedule from now. // If interval changed, reschedule from now.
if (patch.intervalMinutes !== undefined && patch.intervalMinutes !== prev.intervalMinutes) { if (
patch.intervalMinutes !== undefined &&
patch.intervalMinutes !== prev.intervalMinutes
) {
merged.nextFireAt = Date.now() + merged.intervalMinutes * 60_000 merged.nextFireAt = Date.now() + merged.intervalMinutes * 60_000
} }
state.exercises[idx] = merged state.exercises[idx] = merged
@@ -206,7 +437,12 @@ export function markDone(
if (!ex) return undefined if (!ex) return undefined
ex.lastDoneAt = Date.now() ex.lastDoneAt = Date.now()
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
appendHistory(id, 'done', actualReps) appendHistory(id, 'done', {
actualReps,
reps: ex.reps,
name: ex.name,
source: 'reminder'
})
scheduleWrite() scheduleWrite()
return ex return ex
} }
@@ -216,7 +452,7 @@ export function snooze(id: string, minutes: number): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id) const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined if (!ex) return undefined
ex.nextFireAt = Date.now() + minutes * 60_000 ex.nextFireAt = Date.now() + minutes * 60_000
appendHistory(id, 'snooze') appendHistory(id, 'snooze', { reps: ex.reps, name: ex.name })
scheduleWrite() scheduleWrite()
return ex return ex
} }
@@ -226,17 +462,36 @@ export function skip(id: string): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id) const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined if (!ex) return undefined
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
appendHistory(id, 'skip') appendHistory(id, 'skip', { reps: ex.reps, name: ex.name })
scheduleWrite() scheduleWrite()
return ex return ex
} }
/**
* Записать выполнение челленджа из match summary в историю. Не привязано
* к конкретному Exercise (челлендж может ссылаться на упражнение, которое
* пользователь даже не создал). Используем синтетический id 'challenge:<id>'.
*/
export function markChallengeDone(challengeId: string, reps: number): void {
const state = getState()
const ch = state.challenges.find((c) => c.id === challengeId)
appendHistory(`challenge:${challengeId}`, 'done', {
actualReps: reps,
reps,
name: ch?.exerciseName ?? ch?.name,
source: 'match'
})
scheduleWrite()
}
export function flushNow(): void { export function flushNow(): void {
if (pendingWrite) { if (pendingWrite) {
clearTimeout(pendingWrite) clearTimeout(pendingWrite)
pendingWrite = null pendingWrite = null
} }
flush() // before-quit вызывает нас когда event-loop уже на пути к выходу — async
// promise не успеет resolved, поэтому sync.
flushSync()
} }
export function getChallenges(): Challenge[] { export function getChallenges(): Challenge[] {
@@ -258,7 +513,9 @@ export function updateChallenge(
const state = getState() const state = getState()
const idx = state.challenges.findIndex((c) => c.id === id) const idx = state.challenges.findIndex((c) => c.id === id)
if (idx === -1) return undefined if (idx === -1) return undefined
state.challenges[idx] = { ...state.challenges[idx], ...patch } // Same id-strip as updateExercise.
const { id: _ignoredId, ...safePatch } = patch as Partial<Challenge>
state.challenges[idx] = { ...state.challenges[idx], ...safePatch }
scheduleWrite() scheduleWrite()
return state.challenges[idx] return state.challenges[idx]
} }
@@ -281,3 +538,56 @@ export function setGameEnabled(id: GameId, enabled: boolean): void {
state.gamesEnabled = { ...state.gamesEnabled, [id]: enabled } state.gamesEnabled = { ...state.gamesEnabled, [id]: enabled }
scheduleWrite() scheduleWrite()
} }
// ----- Export / Import -----
/**
* Полный snapshot persisted-state (включая историю и schema-version).
* Используется для backup'а или переноса на другую машину.
*/
export function exportState(): string {
const state = getState()
return JSON.stringify(
{
__schemaVersion: CURRENT_SCHEMA_VERSION,
__exportedAt: new Date().toISOString(),
__appVersion: app.getVersion(),
...state
},
null,
2
)
}
/**
* Импорт snapshot'а. Перезаписывает текущий state. Возвращает true при
* успехе. Идёт через тот же coerce + runMigrations что и load() — это
* валидирует тип/диапазоны.
*
* НЕ объединяет с текущим state (merge сложен: дубликаты id, конфликты
* settings) — простое replace. Перед импортом UI должен спросить
* подтверждение.
*/
export function importState(raw: string): boolean {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (e) {
log.warn('[store] import: invalid JSON', e)
return false
}
if (!isValidParsed(parsed)) {
log.warn('[store] import: expected object')
return false
}
try {
const migrated = runMigrations(parsed)
const coerced = coerce(migrated)
cache = coerced
flushSync()
return true
} catch (e) {
log.error('[store] import: coerce/migrate failed', e)
return false
}
}

View File

@@ -1,11 +1,47 @@
import { Tray, Menu, nativeImage, app } from 'electron' import { Tray, Menu, nativeImage, app } from 'electron'
import { join } from 'node:path' import { join } from 'node:path'
import { showMainWindow } from './windows' import { showMainWindow } from './windows'
import { isPaused, setPaused, forceCheck } from './scheduler' import { forceCheck } from './scheduler'
import { snoozeAll } from './state-actions' import { broadcastState, snoozeAll } from './state-actions'
import { getSettings, updateSettings } from './store'
import type { Language } from '@shared/types'
let tray: Tray | null = null let tray: Tray | null = null
/**
* Minimal tray-side localisation. The renderer's full i18n dict lives in
* `src/renderer/src/i18n/dict.ts` and isn't reachable from the main process
* tsconfig, so we keep the 5 strings the tray actually uses here.
*/
const TRAY_STRINGS: Record<Language, Record<string, string>> = {
ru: {
open: 'Открыть',
pause: 'Пауза напоминаний',
resume: 'Возобновить напоминания',
snooze15: 'Отложить все на 15 мин',
quit: 'Выход'
},
en: {
open: 'Open',
pause: 'Pause reminders',
resume: 'Resume reminders',
snooze15: 'Snooze all 15 min',
quit: 'Quit'
}
}
function trayLabel(key: string): string {
// getSettings reads from cache; if the store hasn't loaded yet (very early
// boot) it lazily reads from disk. Defaults to 'ru' if anything goes wrong.
let lang: Language = 'ru'
try {
lang = getSettings().language ?? 'ru'
} catch {
/* keep default */
}
return TRAY_STRINGS[lang]?.[key] ?? TRAY_STRINGS.ru[key] ?? key
}
function resolveTrayIcon(): Electron.NativeImage { function resolveTrayIcon(): Electron.NativeImage {
// Try resources/, fallback to a transparent 16x16 if missing during dev. // Try resources/, fallback to a transparent 16x16 if missing during dev.
const candidates = [ const candidates = [
@@ -33,25 +69,30 @@ export function createTray(): Tray {
export function refreshMenu(): void { export function refreshMenu(): void {
if (!tray) return if (!tray) return
const paused = isPaused() // Single source of truth — settings.globalEnabled. Раньше tray держал
// отдельный scheduler-local `paused` flag, который не синхронизировался
// с Dashboard'ом (там кнопка читает globalEnabled). Теперь оба пути
// правят одно поле.
const paused = !getSettings().globalEnabled
const menu = Menu.buildFromTemplate([ const menu = Menu.buildFromTemplate([
{ label: 'Открыть', click: () => showMainWindow() }, { label: trayLabel('open'), click: () => showMainWindow() },
{ type: 'separator' }, { type: 'separator' },
{ {
label: paused ? 'Возобновить напоминания' : 'Пауза напоминаний', label: paused ? trayLabel('resume') : trayLabel('pause'),
click: () => { click: () => {
setPaused(!paused) updateSettings({ globalEnabled: paused }) // toggle
broadcastState() // чтобы Dashboard перерисовал кнопку сразу
refreshMenu() refreshMenu()
if (!paused) forceCheck() if (paused) forceCheck() // resuming — догнать пропущенные fires
} }
}, },
{ {
label: 'Отложить все на 15 мин', label: trayLabel('snooze15'),
click: () => snoozeAll(15) click: () => snoozeAll(15)
}, },
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Выход', label: trayLabel('quit'),
click: () => { click: () => {
app.quit() app.quit()
} }

View File

@@ -2,18 +2,35 @@ import { app, BrowserWindow } from 'electron'
import { autoUpdater } from 'electron-updater' import { autoUpdater } from 'electron-updater'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import type { UpdaterStatus } from '@shared/types' import type { UpdaterStatus } from '@shared/types'
import { log } from './logger'
let currentStatus: UpdaterStatus = { kind: 'idle' } let currentStatus: UpdaterStatus = { kind: 'idle' }
let lastCheckedAt: number | undefined
let wired = false let wired = false
let checkInterval: NodeJS.Timeout | null = null let checkInterval: NodeJS.Timeout | null = null
// User-initiated checks surface errors. Background checks stay quiet to avoid
// the red banner on transient network blips (504s, DNS, captive portals).
let silentMode = false
const CHECK_INTERVAL_MS = 60 * 60 * 1000 // every hour const CHECK_INTERVAL_MS = 60 * 60 * 1000 // every hour
const BOOT_DELAY_MS = 5_000
// Boot retry: if the first check fails (e.g. network not yet up), retry a few
// times with exponential backoff before giving up until the hourly tick.
const BOOT_RETRY_DELAYS = [30_000, 120_000, 300_000] // 30s, 2min, 5min
export function getUpdaterStatus(): UpdaterStatus { export function getUpdaterStatus(): UpdaterStatus {
return currentStatus return currentStatus
} }
function setStatus(s: UpdaterStatus): void { function setStatus(s: UpdaterStatus): void {
// Preserve lastCheckedAt across status transitions where applicable.
if (s.kind === 'not-available' || s.kind === 'idle') {
if (lastCheckedAt && !('lastCheckedAt' in s)) {
const withTs = s as { lastCheckedAt?: number }
withTs.lastCheckedAt = lastCheckedAt
}
}
currentStatus = s currentStatus = s
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtUpdaterStatus, s) if (!win.isDestroyed()) win.webContents.send(IPC.evtUpdaterStatus, s)
@@ -38,9 +55,14 @@ export function initUpdater(): void {
autoUpdater.autoInstallOnAppQuit = true autoUpdater.autoInstallOnAppQuit = true
autoUpdater.allowDowngrade = false autoUpdater.allowDowngrade = false
autoUpdater.on('checking-for-update', () => setStatus({ kind: 'checking' })) autoUpdater.on('checking-for-update', () => {
// Don't replace the prior status with "checking" during silent polls — the
// UI would briefly flicker for users opening Settings during a tick.
if (!silentMode) setStatus({ kind: 'checking' })
})
autoUpdater.on('update-available', (info) => { autoUpdater.on('update-available', (info) => {
lastCheckedAt = Date.now()
setStatus({ setStatus({
kind: 'available', kind: 'available',
version: info.version, version: info.version,
@@ -50,7 +72,12 @@ export function initUpdater(): void {
}) })
autoUpdater.on('update-not-available', () => { autoUpdater.on('update-not-available', () => {
setStatus({ kind: 'not-available', currentVersion: app.getVersion() }) lastCheckedAt = Date.now()
setStatus({
kind: 'not-available',
currentVersion: app.getVersion(),
lastCheckedAt
})
}) })
autoUpdater.on('download-progress', (p) => { autoUpdater.on('download-progress', (p) => {
@@ -68,23 +95,51 @@ export function initUpdater(): void {
}) })
autoUpdater.on('error', (err) => { autoUpdater.on('error', (err) => {
setStatus({ const message = err instanceof Error ? err.message : String(err)
kind: 'error', if (silentMode) {
message: err instanceof Error ? err.message : String(err) // Background check failed — keep previous status, don't show red banner.
}) // Will retry on the next hourly tick.
log.warn('[updater] silent check failed', message)
return
}
setStatus({ kind: 'error', message })
}) })
// First check on boot (slight delay so window has time to subscribe). // First check on boot with retry-on-failure.
setTimeout(() => { setTimeout(() => {
void checkForUpdates() void bootCheckWithRetry()
}, 5_000) }, BOOT_DELAY_MS)
// Periodic re-check // Periodic re-check (silent).
checkInterval = setInterval(() => { checkInterval = setInterval(() => {
void checkForUpdates() void checkForUpdates({ silent: true })
}, CHECK_INTERVAL_MS) }, CHECK_INTERVAL_MS)
} }
async function bootCheckWithRetry(): Promise<void> {
for (let attempt = 0; attempt <= BOOT_RETRY_DELAYS.length; attempt++) {
await checkForUpdates({ silent: true })
if (
currentStatus.kind === 'available' ||
currentStatus.kind === 'not-available' ||
currentStatus.kind === 'downloaded'
) {
return // success
}
const delay = BOOT_RETRY_DELAYS[attempt]
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))
}
}
export function stopUpdater(): void { export function stopUpdater(): void {
if (checkInterval) { if (checkInterval) {
clearInterval(checkInterval) clearInterval(checkInterval)
@@ -92,15 +147,22 @@ export function stopUpdater(): void {
} }
} }
export async function checkForUpdates(): Promise<UpdaterStatus> { export async function checkForUpdates(
opts: { silent?: boolean } = {}
): Promise<UpdaterStatus> {
if (!app.isPackaged) return currentStatus if (!app.isPackaged) return currentStatus
silentMode = opts.silent ?? false
try { try {
await autoUpdater.checkForUpdates() await autoUpdater.checkForUpdates()
} catch (err) { } catch (err) {
setStatus({ const message = err instanceof Error ? err.message : String(err)
kind: 'error', if (silentMode) {
message: err instanceof Error ? err.message : String(err) log.warn('[updater] silent check failed (sync)', message)
}) } else {
setStatus({ kind: 'error', message })
}
} finally {
silentMode = false
} }
return currentStatus return currentStatus
} }
@@ -119,5 +181,12 @@ export async function downloadUpdate(): Promise<void> {
export function quitAndInstall(): void { export function quitAndInstall(): void {
if (!app.isPackaged) return if (!app.isPackaged) return
autoUpdater.quitAndInstall() // (isSilent=true, isForceRunAfter=true):
// - isSilent: NSIS работает без UI-диалогов установки → restart занимает
// ~1-2 сек вместо ~5-10 (без чёрного окна установщика на половину экрана).
// - isForceRunAfter: гарантируем что после установки приложение запустится
// автоматически, даже если в NSIS-конфиге runAfterFinish был выключен
// для этого сценария. Без этого пользователь нажал «Рестарт» — и остался
// без открытого приложения.
autoUpdater.quitAndInstall(true, true)
} }

467
src/main/validate.test.ts Normal file
View File

@@ -0,0 +1,467 @@
/**
* Тесты для IPC validation layer.
*
* Этот слой — security-boundary между renderer и main. Если он сломается,
* compromised renderer сможет писать в стор NaN, отрицательные, Infinity,
* сверхдлинные строки или undefined-enum'ы. Поэтому покрытие важно для:
*
* 1. Тип-проверок (строка/число/булево/массив)
* 2. Range-checks (reps ∈ [1,9999], minutes ∈ [1,1440] и т.д.)
* 3. Enum allowlist (theme/lang/notify-mode/stat)
* 4. Edge cases: NaN, Infinity, MAX_SAFE_INTEGER, 0, отрицательные, длина строк
* 5. Partial-patch semantics (отсутствие поля ≠ невалидное значение)
* 6. Сложный nested case: quietHours с HH:MM regex и dedup days
*/
import { describe, expect, it } from 'vitest'
import {
validateExerciseInput,
validateExercisePatch,
validateChallengeInput,
validateChallengePatch,
validateSettingsPatch,
validateId,
validateActualReps,
validateSnoozeMinutes
} from './validate'
const validExercise = {
name: 'Push-ups',
reps: 10,
intervalMinutes: 30,
icon: 'Dumbbell',
enabled: true
}
describe('validateExerciseInput', () => {
it('accepts a fully-formed valid input', () => {
expect(validateExerciseInput(validExercise)).toEqual(validExercise)
})
it('rejects non-objects', () => {
expect(validateExerciseInput(null)).toBeNull()
expect(validateExerciseInput(undefined)).toBeNull()
expect(validateExerciseInput('string')).toBeNull()
expect(validateExerciseInput(42)).toBeNull()
expect(validateExerciseInput([])).toBeNull() // arrays not allowed
})
it('rejects missing required fields', () => {
expect(validateExerciseInput({ ...validExercise, name: undefined })).toBeNull()
expect(validateExerciseInput({ ...validExercise, reps: undefined })).toBeNull()
expect(
validateExerciseInput({ ...validExercise, intervalMinutes: undefined })
).toBeNull()
})
it('rejects out-of-range reps', () => {
expect(validateExerciseInput({ ...validExercise, reps: 0 })).toBeNull()
expect(validateExerciseInput({ ...validExercise, reps: -1 })).toBeNull()
expect(validateExerciseInput({ ...validExercise, reps: 10_000 })).toBeNull()
expect(validateExerciseInput({ ...validExercise, reps: NaN })).toBeNull()
expect(
validateExerciseInput({ ...validExercise, reps: Infinity })
).toBeNull()
})
it('truncates reps with Math.trunc (5.7 → 5)', () => {
const r = validateExerciseInput({ ...validExercise, reps: 5.7 })
expect(r?.reps).toBe(5)
})
it('rejects out-of-range intervalMinutes (> 24h)', () => {
expect(
validateExerciseInput({ ...validExercise, intervalMinutes: 0 })
).toBeNull()
expect(
validateExerciseInput({ ...validExercise, intervalMinutes: 1441 })
).toBeNull()
expect(
validateExerciseInput({ ...validExercise, intervalMinutes: -1 })
).toBeNull()
})
it('rejects empty name', () => {
expect(validateExerciseInput({ ...validExercise, name: '' })).toBeNull()
})
it('rejects name longer than MAX_STR_LEN (200)', () => {
expect(
validateExerciseInput({ ...validExercise, name: 'x'.repeat(201) })
).toBeNull()
})
it('accepts name exactly at MAX_STR_LEN', () => {
const r = validateExerciseInput({ ...validExercise, name: 'x'.repeat(200) })
expect(r?.name).toHaveLength(200)
})
it('defaults icon to Activity if missing', () => {
const { icon: _ignored, ...rest } = validExercise
void _ignored
expect(validateExerciseInput(rest)?.icon).toBe('Activity')
})
it('defaults enabled to true if missing', () => {
const { enabled: _ignored, ...rest } = validExercise
void _ignored
expect(validateExerciseInput(rest)?.enabled).toBe(true)
})
// Дизайн validateExerciseInput: required-поля (name/reps/intervalMinutes)
// строгие — невалидное значение reject'ит весь input. Optional-поля
// (icon/enabled) lenient — невалидное молча подменяется дефолтом. Это
// фиксирует контракт: malicious renderer не сможет создать запись с
// reps=-1, но если он пришлёт `enabled: 'yes'`, получит просто enabled=true.
it('coerces invalid enabled to true (lenient default for optional fields)', () => {
expect(
validateExerciseInput({ ...validExercise, enabled: 'yes' })?.enabled
).toBe(true)
expect(
validateExerciseInput({ ...validExercise, enabled: 1 })?.enabled
).toBe(true)
})
// А вот в patch optional-поля строгие — нет defaults, есть `if (v ===
// undefined) return null`. Это правильнее: если renderer пришёл с патчем,
// в котором есть поле, оно должно быть валидным.
it('strict patch: rejects invalid enabled in patch (unlike input)', () => {
expect(validateExercisePatch({ enabled: 'yes' })).toBeNull()
expect(validateExercisePatch({ enabled: 1 })).toBeNull()
})
it('rejects non-string name', () => {
expect(validateExerciseInput({ ...validExercise, name: 42 })).toBeNull()
expect(validateExerciseInput({ ...validExercise, name: null })).toBeNull()
})
})
describe('validateExercisePatch', () => {
it('accepts an empty patch (no-op update)', () => {
expect(validateExercisePatch({})).toEqual({})
})
it('accepts partial patches', () => {
expect(validateExercisePatch({ reps: 12 })).toEqual({ reps: 12 })
expect(validateExercisePatch({ name: 'New' })).toEqual({ name: 'New' })
expect(validateExercisePatch({ enabled: false })).toEqual({ enabled: false })
})
it('rejects patch with a single invalid field', () => {
// Patch is all-or-nothing: one bad field rejects the whole patch.
expect(validateExercisePatch({ name: 'OK', reps: -1 })).toBeNull()
expect(validateExercisePatch({ name: '', reps: 10 })).toBeNull()
})
it('rejects non-object', () => {
expect(validateExercisePatch(null)).toBeNull()
expect(validateExercisePatch([])).toBeNull()
})
it('accepts nextFireAt and lastDoneAt with valid ranges', () => {
expect(validateExercisePatch({ nextFireAt: 0 })).toEqual({ nextFireAt: 0 })
expect(validateExercisePatch({ lastDoneAt: 1_000_000_000_000 })).toEqual({
lastDoneAt: 1_000_000_000_000
})
})
it('rejects negative timestamps', () => {
expect(validateExercisePatch({ nextFireAt: -1 })).toBeNull()
expect(validateExercisePatch({ lastDoneAt: -1 })).toBeNull()
})
it('rejects NaN/Infinity timestamps', () => {
expect(validateExercisePatch({ nextFireAt: NaN })).toBeNull()
expect(validateExercisePatch({ nextFireAt: Infinity })).toBeNull()
})
})
describe('validateChallengeInput', () => {
const valid = {
name: 'Deaths → squats',
gameId: 'dota2',
stat: 'deaths' as const,
multiplier: 3,
exerciseName: 'Приседания',
icon: 'Activity',
enabled: true
}
it('accepts valid input', () => {
expect(validateChallengeInput(valid)).toEqual(valid)
})
it('rejects unknown stat', () => {
expect(validateChallengeInput({ ...valid, stat: 'pizza' })).toBeNull()
})
it('accepts all valid stats', () => {
const stats = ['deaths', 'kills', 'assists', 'last_hits', 'denies', 'duration_min']
for (const stat of stats) {
expect(validateChallengeInput({ ...valid, stat })).not.toBeNull()
}
})
it('rejects negative multiplier', () => {
expect(validateChallengeInput({ ...valid, multiplier: -1 })).toBeNull()
})
it('rejects multiplier > 1000', () => {
expect(validateChallengeInput({ ...valid, multiplier: 1001 })).toBeNull()
})
it('accepts zero multiplier (legitimate "disable" semantics)', () => {
expect(validateChallengeInput({ ...valid, multiplier: 0 })?.multiplier).toBe(0)
})
it('accepts fractional multiplier (e.g. 0.5×)', () => {
expect(validateChallengeInput({ ...valid, multiplier: 0.5 })?.multiplier).toBe(0.5)
})
})
describe('validateChallengePatch', () => {
it('accepts empty patch', () => {
expect(validateChallengePatch({})).toEqual({})
})
it('rejects unknown stat in patch', () => {
expect(validateChallengePatch({ stat: 'mana' })).toBeNull()
})
})
describe('validateSettingsPatch', () => {
it('accepts empty patch', () => {
expect(validateSettingsPatch({})).toEqual({})
})
it('accepts each boolean toggle independently', () => {
expect(validateSettingsPatch({ globalEnabled: false })).toEqual({
globalEnabled: false
})
expect(validateSettingsPatch({ soundEnabled: true })).toEqual({
soundEnabled: true
})
})
it('rejects unknown theme', () => {
expect(validateSettingsPatch({ theme: 'sepia' })).toBeNull()
})
it('accepts all valid themes', () => {
expect(validateSettingsPatch({ theme: 'light' })?.theme).toBe('light')
expect(validateSettingsPatch({ theme: 'dark' })?.theme).toBe('dark')
expect(validateSettingsPatch({ theme: 'system' })?.theme).toBe('system')
})
it('rejects unknown language', () => {
expect(validateSettingsPatch({ language: 'fr' })).toBeNull()
})
it('rejects unknown notification mode', () => {
expect(validateSettingsPatch({ notificationMode: 'sms' })).toBeNull()
})
it('rejects out-of-range snoozeMinutes', () => {
expect(validateSettingsPatch({ snoozeMinutes: 0 })).toBeNull()
expect(validateSettingsPatch({ snoozeMinutes: 1441 })).toBeNull()
expect(validateSettingsPatch({ snoozeMinutes: -5 })).toBeNull()
})
it('accepts voicePromptsEnabled boolean', () => {
expect(validateSettingsPatch({ voicePromptsEnabled: true })).toEqual({
voicePromptsEnabled: true
})
expect(validateSettingsPatch({ voicePromptsEnabled: false })).toEqual({
voicePromptsEnabled: false
})
})
it('rejects non-boolean voicePromptsEnabled in patch', () => {
expect(validateSettingsPatch({ voicePromptsEnabled: 'yes' })).toBeNull()
expect(validateSettingsPatch({ voicePromptsEnabled: 1 })).toBeNull()
})
it('accepts meetingAutoPause boolean', () => {
expect(validateSettingsPatch({ meetingAutoPause: true })).toEqual({
meetingAutoPause: true
})
})
it('rejects non-boolean meetingAutoPause', () => {
expect(validateSettingsPatch({ meetingAutoPause: 'yes' })).toBeNull()
})
describe('lastSeenVersion', () => {
it('accepts valid semver', () => {
const r = validateSettingsPatch({ lastSeenVersion: '0.5.7' })
expect(r?.lastSeenVersion).toBe('0.5.7')
expect(validateSettingsPatch({ lastSeenVersion: '10.20.30' })).toEqual({
lastSeenVersion: '10.20.30'
})
})
it('accepts pre-release suffix', () => {
const r = validateSettingsPatch({ lastSeenVersion: '0.5.7-beta.1' })
expect(r?.lastSeenVersion).toBe('0.5.7-beta.1')
})
it('treats null/undefined as reset to undefined', () => {
const r1 = validateSettingsPatch({ lastSeenVersion: null })
expect(r1).toEqual({ lastSeenVersion: undefined })
const r2 = validateSettingsPatch({ lastSeenVersion: undefined })
// 'lastSeenVersion' is `in raw` even if undefined — both treated reset.
expect(r2).toEqual({ lastSeenVersion: undefined })
})
it('rejects malformed strings', () => {
expect(validateSettingsPatch({ lastSeenVersion: '0.5' })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: 'v0.5.7' })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: 'beta' })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: '' })).toBeNull()
})
it('rejects non-strings', () => {
expect(validateSettingsPatch({ lastSeenVersion: 42 })).toBeNull()
expect(validateSettingsPatch({ lastSeenVersion: ['1', '0', '0'] })).toBeNull()
})
})
describe('quietHours subobject', () => {
const baseQh = {
enabled: true,
from: '22:00',
to: '08:00',
days: [0, 1, 2, 3, 4, 5, 6]
}
it('accepts a valid quietHours', () => {
expect(validateSettingsPatch({ quietHours: baseQh })?.quietHours).toEqual(
baseQh
)
})
it('rejects non-object quietHours', () => {
expect(validateSettingsPatch({ quietHours: 'always' })).toBeNull()
expect(validateSettingsPatch({ quietHours: null })).toBeNull()
})
it('rejects malformed HH:MM', () => {
expect(
validateSettingsPatch({ quietHours: { ...baseQh, from: '2500' } })
).toBeNull()
expect(
validateSettingsPatch({ quietHours: { ...baseQh, to: 'bedtime' } })
).toBeNull()
expect(
validateSettingsPatch({ quietHours: { ...baseQh, from: '8' } })
).toBeNull()
})
it('accepts HH:MM with 1-digit hour (9:30)', () => {
// Regex is /^\d{1,2}:\d{2}$/ — допускаем «9:30», парсер сам разберётся.
const r = validateSettingsPatch({
quietHours: { ...baseQh, from: '9:30' }
})
expect(r?.quietHours?.from).toBe('9:30')
})
it('dedupes days array', () => {
const r = validateSettingsPatch({
quietHours: { ...baseQh, days: [1, 2, 2, 3, 1] }
})
expect(r?.quietHours?.days).toEqual([1, 2, 3])
})
it('rejects out-of-range day (7)', () => {
expect(
validateSettingsPatch({ quietHours: { ...baseQh, days: [0, 7] } })
).toBeNull()
})
it('rejects negative day', () => {
expect(
validateSettingsPatch({ quietHours: { ...baseQh, days: [-1] } })
).toBeNull()
})
it('rejects non-array days', () => {
expect(
validateSettingsPatch({ quietHours: { ...baseQh, days: 'all' } })
).toBeNull()
})
it('accepts empty days array (window effectively disabled)', () => {
const r = validateSettingsPatch({
quietHours: { ...baseQh, days: [] }
})
expect(r?.quietHours?.days).toEqual([])
})
})
})
describe('validateId', () => {
it('accepts reasonable id strings', () => {
expect(validateId('abc')).toBe('abc')
expect(validateId('uuid-v4-style-thing-123')).toBe('uuid-v4-style-thing-123')
})
it('rejects non-strings', () => {
expect(validateId(42)).toBeNull()
expect(validateId(null)).toBeNull()
expect(validateId(undefined)).toBeNull()
expect(validateId({})).toBeNull()
})
it('rejects empty string', () => {
expect(validateId('')).toBeNull()
})
it('rejects strings longer than 64 chars', () => {
expect(validateId('x'.repeat(65))).toBeNull()
})
})
describe('validateActualReps', () => {
it('returns undefined for undefined/null (means: use planned reps)', () => {
expect(validateActualReps(undefined)).toBeUndefined()
expect(validateActualReps(null)).toBeUndefined()
})
it('accepts zero (partial completion = "did 0 of 10")', () => {
expect(validateActualReps(0)).toBe(0)
})
it('accepts large values up to cap', () => {
expect(validateActualReps(100_000)).toBe(100_000)
})
it('rejects negative', () => {
expect(validateActualReps(-1)).toBeUndefined()
})
it('rejects values above cap', () => {
expect(validateActualReps(100_001)).toBeUndefined()
})
it('rejects NaN/Infinity', () => {
expect(validateActualReps(NaN)).toBeUndefined()
expect(validateActualReps(Infinity)).toBeUndefined()
})
})
describe('validateSnoozeMinutes', () => {
it('accepts valid minutes', () => {
expect(validateSnoozeMinutes(15)).toBe(15)
expect(validateSnoozeMinutes(1)).toBe(1)
expect(validateSnoozeMinutes(1440)).toBe(1440)
})
it('rejects 0 and above 24h', () => {
expect(validateSnoozeMinutes(0)).toBeNull()
expect(validateSnoozeMinutes(1441)).toBeNull()
})
it('rejects non-numbers', () => {
expect(validateSnoozeMinutes('15')).toBeNull()
expect(validateSnoozeMinutes(null)).toBeNull()
})
})

381
src/main/validate.ts Normal file
View File

@@ -0,0 +1,381 @@
/**
* Hand-rolled runtime validators for IPC payloads.
*
* TypeScript types are erased at compile time — a compromised or buggy
* renderer can still send arbitrary JSON across the IPC boundary. These
* helpers enforce shape, type and range BEFORE the data hits the store.
*
* Philosophy: be lenient with unknown fields (drop them silently), strict
* about known fields (reject the call if a known field is the wrong type
* or out of range). Never throw to the renderer; return a sanitised value
* or `null` and the caller decides what to do.
*/
import type {
Challenge,
Exercise,
GameStat,
Settings,
Theme,
Language,
NotificationMode,
ReminderCategory
} from '@shared/types'
const MAX_STR_LEN = 200
const VALID_THEMES: Theme[] = ['system', 'light', 'dark']
const VALID_LANGS: Language[] = ['ru', 'en']
const VALID_NOTIFY: NotificationMode[] = ['toast', 'modal', 'both']
const VALID_STATS: GameStat[] = [
'deaths',
'kills',
'assists',
'last_hits',
'denies',
'duration_min'
]
const VALID_CATEGORIES: ReminderCategory[] = [
'exercise',
'hydration',
'eyes',
'posture'
]
const HHMM_RE = /^\d{1,2}:\d{2}$/
function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
function safeStr(v: unknown, max = MAX_STR_LEN): string | undefined {
if (typeof v !== 'string') return undefined
if (v.length === 0 || v.length > max) return undefined
return v
}
function intInRange(v: unknown, min: number, max: number): number | undefined {
if (typeof v !== 'number' || !Number.isFinite(v)) return undefined
const n = Math.trunc(v)
if (n < min || n > max) return undefined
return n
}
function numInRange(v: unknown, min: number, max: number): number | undefined {
if (typeof v !== 'number' || !Number.isFinite(v)) return undefined
if (v < min || v > max) return undefined
return v
}
function bool(v: unknown): boolean | undefined {
return typeof v === 'boolean' ? v : undefined
}
function oneOf<T extends string>(
v: unknown,
allowed: readonly T[]
): T | undefined {
return typeof v === 'string' && (allowed as readonly string[]).includes(v)
? (v as T)
: undefined
}
// -----------------------------------------------------------------------
// Exercise validators
// -----------------------------------------------------------------------
export function validateExerciseInput(
raw: unknown
): Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'> | null {
if (!isObj(raw)) return null
const name = safeStr(raw.name)
const reps = intInRange(raw.reps, 1, 9999)
const intervalMinutes = intInRange(raw.intervalMinutes, 1, 24 * 60)
const icon = safeStr(raw.icon, 64) ?? 'Activity'
const enabled = bool(raw.enabled) ?? true
const category = oneOf(raw.category, VALID_CATEGORIES) // undefined OK = default
const dailyGoal =
raw.dailyGoal === undefined || raw.dailyGoal === null
? undefined
: intInRange(raw.dailyGoal, 1, 100_000)
// dailyGoal: undefined = не задан (нет soft-cap'a), null от UI приводим к
// undefined; иначе — должен пройти int-range, иначе reject (нельзя
// отправить из renderer'а NaN/негатив и тихо обнулить).
if (raw.dailyGoal !== undefined && raw.dailyGoal !== null && dailyGoal === undefined) {
return null
}
if (
name === undefined ||
reps === undefined ||
intervalMinutes === undefined
) {
return null
}
const out: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'> = {
name,
reps,
intervalMinutes,
icon,
enabled
}
if (category !== undefined) out.category = category
if (dailyGoal !== undefined) out.dailyGoal = dailyGoal
const adaptive = bool(raw.adaptive)
if (adaptive !== undefined) out.adaptive = adaptive
return out
}
export function validateExercisePatch(
raw: unknown
): Partial<Omit<Exercise, 'id'>> | null {
if (!isObj(raw)) return null
const out: Partial<Omit<Exercise, 'id'>> = {}
if ('name' in raw) {
const v = safeStr(raw.name)
if (v === undefined) return null
out.name = v
}
if ('reps' in raw) {
const v = intInRange(raw.reps, 1, 9999)
if (v === undefined) return null
out.reps = v
}
if ('intervalMinutes' in raw) {
const v = intInRange(raw.intervalMinutes, 1, 24 * 60)
if (v === undefined) return null
out.intervalMinutes = v
}
if ('icon' in raw) {
const v = safeStr(raw.icon, 64)
if (v === undefined) return null
out.icon = v
}
if ('enabled' in raw) {
const v = bool(raw.enabled)
if (v === undefined) return null
out.enabled = v
}
if ('category' in raw) {
const v = oneOf(raw.category, VALID_CATEGORIES)
if (v === undefined) return null
out.category = v
}
if ('dailyGoal' in raw) {
// Допустим null/undefined как «снять goal».
if (raw.dailyGoal === null || raw.dailyGoal === undefined) {
out.dailyGoal = undefined
} else {
const v = intInRange(raw.dailyGoal, 1, 100_000)
if (v === undefined) return null
out.dailyGoal = v
}
}
if ('adaptive' in raw) {
const v = bool(raw.adaptive)
if (v === undefined) return null
out.adaptive = v
}
// Allow scheduler-controlled fields to be patched (used by store.markDone
// through this same boundary), but range-check them.
if ('nextFireAt' in raw) {
const v = numInRange(raw.nextFireAt, 0, Number.MAX_SAFE_INTEGER)
if (v === undefined) return null
out.nextFireAt = v
}
if ('lastDoneAt' in raw) {
const v = numInRange(raw.lastDoneAt, 0, Number.MAX_SAFE_INTEGER)
if (v === undefined) return null
out.lastDoneAt = v
}
return out
}
// -----------------------------------------------------------------------
// Challenge validators
// -----------------------------------------------------------------------
export function validateChallengeInput(
raw: unknown
): Omit<Challenge, 'id'> | null {
if (!isObj(raw)) return null
const name = safeStr(raw.name)
const gameId = safeStr(raw.gameId, 32)
const stat = oneOf(raw.stat, VALID_STATS)
const multiplier = numInRange(raw.multiplier, 0, 1000)
const exerciseName = safeStr(raw.exerciseName)
const icon = safeStr(raw.icon, 64) ?? 'Activity'
const enabled = bool(raw.enabled) ?? true
if (
name === undefined ||
gameId === undefined ||
stat === undefined ||
multiplier === undefined ||
exerciseName === undefined
) {
return null
}
return {
name,
gameId: gameId as Challenge['gameId'],
stat,
multiplier,
exerciseName,
icon,
enabled
}
}
export function validateChallengePatch(
raw: unknown
): Partial<Omit<Challenge, 'id'>> | null {
if (!isObj(raw)) return null
const out: Partial<Omit<Challenge, 'id'>> = {}
if ('name' in raw) {
const v = safeStr(raw.name)
if (v === undefined) return null
out.name = v
}
if ('exerciseName' in raw) {
const v = safeStr(raw.exerciseName)
if (v === undefined) return null
out.exerciseName = v
}
if ('stat' in raw) {
const v = oneOf(raw.stat, VALID_STATS)
if (v === undefined) return null
out.stat = v
}
if ('multiplier' in raw) {
const v = numInRange(raw.multiplier, 0, 1000)
if (v === undefined) return null
out.multiplier = v
}
if ('icon' in raw) {
const v = safeStr(raw.icon, 64)
if (v === undefined) return null
out.icon = v
}
if ('enabled' in raw) {
const v = bool(raw.enabled)
if (v === undefined) return null
out.enabled = v
}
return out
}
// -----------------------------------------------------------------------
// Settings validators
// -----------------------------------------------------------------------
export function validateSettingsPatch(raw: unknown): Partial<Settings> | null {
if (!isObj(raw)) return null
const out: Partial<Settings> = {}
if ('globalEnabled' in raw) {
const v = bool(raw.globalEnabled)
if (v === undefined) return null
out.globalEnabled = v
}
if ('startWithWindows' in raw) {
const v = bool(raw.startWithWindows)
if (v === undefined) return null
out.startWithWindows = v
}
if ('startMinimized' in raw) {
const v = bool(raw.startMinimized)
if (v === undefined) return null
out.startMinimized = v
}
if ('minimizeToTray' in raw) {
const v = bool(raw.minimizeToTray)
if (v === undefined) return null
out.minimizeToTray = v
}
if ('soundEnabled' in raw) {
const v = bool(raw.soundEnabled)
if (v === undefined) return null
out.soundEnabled = v
}
if ('voicePromptsEnabled' in raw) {
const v = bool(raw.voicePromptsEnabled)
if (v === undefined) return null
out.voicePromptsEnabled = v
}
if ('meetingAutoPause' in raw) {
const v = bool(raw.meetingAutoPause)
if (v === undefined) return null
out.meetingAutoPause = v
}
if ('lastSeenVersion' in raw) {
// Принимаем строку 0.0.0 .. 999.999.999 (semver-light) или null/undefined
// для сброса.
if (raw.lastSeenVersion === null || raw.lastSeenVersion === undefined) {
out.lastSeenVersion = undefined
} else {
const v = safeStr(raw.lastSeenVersion, 32)
if (v === undefined || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(v)) return null
out.lastSeenVersion = v
}
}
if ('notificationMode' in raw) {
const v = oneOf(raw.notificationMode, VALID_NOTIFY)
if (v === undefined) return null
out.notificationMode = v
}
if ('theme' in raw) {
const v = oneOf(raw.theme, VALID_THEMES)
if (v === undefined) return null
out.theme = v
}
if ('language' in raw) {
const v = oneOf(raw.language, VALID_LANGS)
if (v === undefined) return null
out.language = v
}
if ('snoozeMinutes' in raw) {
const v = intInRange(raw.snoozeMinutes, 1, 24 * 60)
if (v === undefined) return null
out.snoozeMinutes = v
}
if ('quietHours' in raw) {
const qh = raw.quietHours
if (!isObj(qh)) return null
const enabled = bool(qh.enabled)
const from = safeStr(qh.from, 8)
const to = safeStr(qh.to, 8)
if (
enabled === undefined ||
from === undefined ||
to === undefined ||
!HHMM_RE.test(from) ||
!HHMM_RE.test(to)
) {
return null
}
if (!Array.isArray(qh.days)) return null
const days: number[] = []
for (const d of qh.days) {
const n = intInRange(d, 0, 6)
if (n === undefined) return null
if (!days.includes(n)) days.push(n)
}
out.quietHours = { enabled, from, to, days }
}
return out
}
// -----------------------------------------------------------------------
// Misc tiny validators
// -----------------------------------------------------------------------
export function validateId(raw: unknown): string | null {
// UUIDs from store.ts via randomUUID(); accept any reasonable string id.
const v = safeStr(raw, 64)
return v ?? null
}
export function validateActualReps(raw: unknown): number | undefined {
if (raw === undefined || raw === null) return undefined
return intInRange(raw, 0, 100000) ?? undefined
}
export function validateSnoozeMinutes(raw: unknown): number | null {
return intInRange(raw, 1, 24 * 60) ?? null
}

View File

@@ -1,4 +1,5 @@
import { BrowserWindow, shell, screen, app, nativeImage } from 'electron' import { BrowserWindow, shell, screen, app, nativeImage } from 'electron'
import { IPC } from '../shared/ipc'
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
@@ -24,6 +25,48 @@ function windowIcon(): Electron.NativeImage | undefined {
return undefined return undefined
} }
/**
* Allowlist of schemes safe to hand to the OS via shell.openExternal.
* The renderer is hostile-by-default — XSS or a malicious dep could ask us
* to open `file:`, `javascript:`, `ms-msdt:`, `steam://install/...` etc.
* (Custom URI handlers have historically been RCE vectors.)
*/
const ALLOWED_EXTERNAL_SCHEMES = new Set(['http:', 'https:', 'mailto:'])
function isSafeExternalUrl(url: string): boolean {
try {
return ALLOWED_EXTERNAL_SCHEMES.has(new URL(url).protocol)
} catch {
return false
}
}
function installSafeNavigation(win: BrowserWindow): void {
// Any popup attempt: open externally only if scheme is in our allowlist.
win.webContents.setWindowOpenHandler(({ url }) => {
if (isSafeExternalUrl(url)) {
void shell.openExternal(url)
} else {
console.warn(
'[windows] blocked openExternal for non-allowlisted URL:',
url
)
}
return { action: 'deny' }
})
// Renderer must never navigate the BrowserWindow to a third-party origin.
// We always load file:// or the dev URL; anything else is suspect.
win.webContents.on('will-navigate', (event, url) => {
const devUrl = process.env['ELECTRON_RENDERER_URL']
const allow =
url.startsWith('file://') || (devUrl && url.startsWith(devUrl))
if (!allow) {
event.preventDefault()
console.warn('[windows] blocked will-navigate to:', url)
}
})
}
function loadRoute(win: BrowserWindow, route: 'main' | 'reminder'): void { function loadRoute(win: BrowserWindow, route: 'main' | 'reminder'): void {
const devUrl = process.env['ELECTRON_RENDERER_URL'] const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) { if (devUrl) {
@@ -48,8 +91,13 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1100, width: 1100,
height: 720, height: 720,
minWidth: 900, // Минимум подобран так, чтобы:
minHeight: 600, // - срабатывал Tailwind `lg:` (≥1024px) → 4 hero-stat в один ряд, а не 2×2
// - сайдбар (256px) + контент (max-w-5xl, padding lg:px-10) помещались без
// горизонтального скролла heatmap'а и карточек упражнений
// - по вертикали оставался запас на header + stats + heatmap без обрезки
minWidth: 1100,
minHeight: 700,
show: false, show: false,
frame: false, frame: false,
backgroundColor: '#0f1117', backgroundColor: '#0f1117',
@@ -58,7 +106,12 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
...(icon ? { icon } : {}), ...(icon ? { icon } : {}),
webPreferences: { webPreferences: {
preload: preloadPath(), preload: preloadPath(),
sandbox: false, // sandbox: true — preload использует только contextBridge + ipcRenderer
// (оба sandbox-safe), никаких Node-built-ins (fs/path/child_process).
// Sandbox изолирует renderer от Chromium GPU/IPC процессов на уровне
// OS-сэндбокса; даже RCE через зависимости renderer'а не получит
// полного Node-доступа из preload.
sandbox: true,
contextIsolation: true, contextIsolation: true,
nodeIntegration: false nodeIntegration: false
} }
@@ -68,10 +121,17 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
if (showImmediately) win.show() if (showImmediately) win.show()
}) })
win.webContents.setWindowOpenHandler(({ url }) => { // Сообщаем рендереру об изменении max-состояния, чтобы он мог менять
shell.openExternal(url) // иконку (квадрат ↔ «двойной квадрат») в кастомном тайтлбаре.
return { action: 'deny' } const emitMaxState = (maximized: boolean): void => {
}) if (!win.isDestroyed()) {
win.webContents.send(IPC.evtMaximizeChanged, maximized)
}
}
win.on('maximize', () => emitMaxState(true))
win.on('unmaximize', () => emitMaxState(false))
installSafeNavigation(win)
loadRoute(win, 'main') loadRoute(win, 'main')
mainWindow = win mainWindow = win
@@ -116,13 +176,14 @@ export function createReminderWindow(): BrowserWindow {
...(icon ? { icon } : {}), ...(icon ? { icon } : {}),
webPreferences: { webPreferences: {
preload: preloadPath(), preload: preloadPath(),
sandbox: false, sandbox: true, // см. createMainWindow — preload не использует Node.
contextIsolation: true, contextIsolation: true,
nodeIntegration: false nodeIntegration: false
} }
}) })
win.setAlwaysOnTop(true, 'screen-saver') win.setAlwaysOnTop(true, 'screen-saver')
installSafeNavigation(win)
loadRoute(win, 'reminder') loadRoute(win, 'reminder')
win.on('closed', () => { win.on('closed', () => {

View File

@@ -17,7 +17,8 @@ type Unsub = () => void
type Handler<T> = (payload: T) => void type Handler<T> = (payload: T) => void
function on<T>(channel: string, handler: Handler<T>): Unsub { function on<T>(channel: string, handler: Handler<T>): Unsub {
const listener = (_e: Electron.IpcRendererEvent, payload: T): void => handler(payload) const listener = (_e: Electron.IpcRendererEvent, payload: T): void =>
handler(payload)
ipcRenderer.on(channel, listener) ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener) return () => ipcRenderer.removeListener(channel, listener)
} }
@@ -44,7 +45,11 @@ const api = {
ipcRenderer.invoke(IPC.updateSettings, patch), ipcRenderer.invoke(IPC.updateSettings, patch),
getAccentColor: (): Promise<string> => ipcRenderer.invoke(IPC.getAccentColor), getAccentColor: (): Promise<string> => ipcRenderer.invoke(IPC.getAccentColor),
getOsTheme: (): Promise<'light' | 'dark'> => ipcRenderer.invoke(IPC.getOsTheme), 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), pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll),
resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll), resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll),
@@ -52,6 +57,9 @@ const api = {
reminderClose: (): Promise<void> => ipcRenderer.invoke(IPC.reminderClose), reminderClose: (): Promise<void> => ipcRenderer.invoke(IPC.reminderClose),
minimizeMain: (): void => ipcRenderer.send(IPC.minimizeMain), minimizeMain: (): void => ipcRenderer.send(IPC.minimizeMain),
toggleMaximizeMain: (): void => ipcRenderer.send(IPC.toggleMaximizeMain),
isMaximizedMain: (): Promise<boolean> =>
ipcRenderer.invoke(IPC.isMaximizedMain),
closeMain: (): void => ipcRenderer.send(IPC.closeMain), closeMain: (): void => ipcRenderer.send(IPC.closeMain),
hideMain: (): void => ipcRenderer.send(IPC.hideMain), hideMain: (): void => ipcRenderer.send(IPC.hideMain),
@@ -69,25 +77,43 @@ const api = {
// Challenges // Challenges
addChallenge: (input: Omit<Challenge, 'id'>): Promise<Challenge> => addChallenge: (input: Omit<Challenge, 'id'>): Promise<Challenge> =>
ipcRenderer.invoke(IPC.addChallenge, input), ipcRenderer.invoke(IPC.addChallenge, input),
updateChallenge: (id: string, patch: Partial<Challenge>): Promise<Challenge> => updateChallenge: (
ipcRenderer.invoke(IPC.updateChallenge, id, patch), id: string,
patch: Partial<Challenge>
): Promise<Challenge> => ipcRenderer.invoke(IPC.updateChallenge, id, patch),
deleteChallenge: (id: string): Promise<boolean> => deleteChallenge: (id: string): Promise<boolean> =>
ipcRenderer.invoke(IPC.deleteChallenge, id), ipcRenderer.invoke(IPC.deleteChallenge, id),
toggleChallenge: (id: string, enabled: boolean): Promise<Challenge> => toggleChallenge: (id: string, enabled: boolean): Promise<Challenge> =>
ipcRenderer.invoke(IPC.toggleChallenge, id, enabled), ipcRenderer.invoke(IPC.toggleChallenge, id, enabled),
markChallengeDone: (id: string, reps: number): Promise<boolean> =>
ipcRenderer.invoke(IPC.markChallengeDone, id, reps),
closeMatchSummary: (): Promise<void> => ipcRenderer.invoke(IPC.closeMatchSummary), closeMatchSummary: (): Promise<void> =>
ipcRenderer.invoke(IPC.closeMatchSummary),
simulateMatchEnd: (id: GameId, stats: Record<string, number>): Promise<void> => // Dev-only: synthesize a match-end event from the renderer. The channel is
ipcRenderer.invoke('dev:simulateMatchEnd', id, stats), // not registered in production builds (see src/main/ipc.ts), so this
// function will reject in shipped binaries even though it's exposed.
// Gated at the preload level too so the bundler can dead-code-eliminate it.
...(import.meta.env.MODE !== 'production'
? {
simulateMatchEnd: (
id: GameId,
stats: Record<string, number>
): Promise<void> =>
ipcRenderer.invoke(IPC.devSimulateMatchEnd, id, stats)
}
: {}),
// Auto-updater // Auto-updater
updaterStatus: (): Promise<UpdaterStatus> => updaterStatus: (): Promise<UpdaterStatus> =>
ipcRenderer.invoke(IPC.updaterStatus), ipcRenderer.invoke(IPC.updaterStatus),
updaterCheck: (): Promise<UpdaterStatus> => updaterCheck: (): Promise<UpdaterStatus> =>
ipcRenderer.invoke(IPC.updaterCheck), ipcRenderer.invoke(IPC.updaterCheck),
updaterDownload: (): Promise<void> => ipcRenderer.invoke(IPC.updaterDownload), // Fire-and-forget. Прогресс и завершение прилетают через onUpdaterStatus —
updaterInstall: (): Promise<void> => ipcRenderer.invoke(IPC.updaterInstall), // renderer не должен `await`'ить, иначе busy-state висит весь download.
updaterDownload: (): void => ipcRenderer.send(IPC.updaterDownload),
updaterInstall: (): void => ipcRenderer.send(IPC.updaterInstall),
// History // History
getHistory: (sinceMs?: number): Promise<HistoryEntry[]> => getHistory: (sinceMs?: number): Promise<HistoryEntry[]> =>
@@ -95,15 +121,36 @@ const api = {
clearHistory: (beforeTs?: number): Promise<number> => clearHistory: (beforeTs?: number): Promise<number> =>
ipcRenderer.invoke(IPC.clearHistory, beforeTs), ipcRenderer.invoke(IPC.clearHistory, beforeTs),
// Export / Import — открывают native save/open dialogs из main process.
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), onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h), onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h), onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),
onStateChanged: (h: Handler<AppState>): Unsub => on(IPC.evtStateChanged, h), onStateChanged: (h: Handler<AppState>): Unsub => on(IPC.evtStateChanged, h),
onThemeChanged: (h: Handler<'light' | 'dark'>): Unsub => on(IPC.evtThemeChanged, h), onThemeChanged: (h: Handler<'light' | 'dark'>): Unsub =>
on(IPC.evtThemeChanged, h),
onAccentChanged: (h: Handler<string>): Unsub => on(IPC.evtAccentChanged, h), onAccentChanged: (h: Handler<string>): Unsub => on(IPC.evtAccentChanged, h),
onGamesChanged: (h: Handler<GameStatus[]>): Unsub => on(IPC.evtGamesChanged, h), onGamesChanged: (h: Handler<GameStatus[]>): Unsub =>
on(IPC.evtGamesChanged, h),
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub => onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
on(IPC.evtUpdaterStatus, h) on(IPC.evtUpdaterStatus, h),
onMaximizeChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMaximizeChanged, h),
onMeetingChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMeetingChanged, h),
onHistoryChanged: (h: Handler<void>): Unsub =>
on(IPC.evtHistoryChanged, h)
} }
contextBridge.exposeInMainWorld('api', api) contextBridge.exposeInMainWorld('api', api)

View File

@@ -3,11 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; script-src 'self'" /> <!--
CSP: всё локально, без внешних origins. Шрифты подгружаются через
@fontsource/* импорты в globals.css. style-src 'unsafe-inline' нужен
для Tailwind utility-классов и инлайн-стилей framer-motion. font-src
включает data: на случай если кто-то вставит base64 SVG-glyph.
-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; script-src 'self'; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'" />
<title>Exercise Reminder</title> <title>Exercise Reminder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -3,6 +3,10 @@ import { HashRouter, Route, Routes, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { Sidebar } from './components/Sidebar' import { Sidebar } from './components/Sidebar'
import { Titlebar } from './components/Titlebar' 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 Dashboard from './pages/Dashboard'
import Exercises from './pages/Exercises' import Exercises from './pages/Exercises'
import GamesPage from './pages/Games' import GamesPage from './pages/Games'
@@ -10,34 +14,121 @@ import ChallengesPage from './pages/Challenges'
import SettingsPage from './pages/Settings' import SettingsPage from './pages/Settings'
import { subscribeToBackend, useAppStore } from './store/appStore' import { subscribeToBackend, useAppStore } from './store/appStore'
// Module-level guard so React 18 StrictMode's double-invocation of mount
// effects (in dev only) doesn't subscribe to backend IPC twice.
let backendSubscribed = false
export default function App(): JSX.Element { export default function App(): JSX.Element {
const hydrated = useAppStore((s) => s.hydrated) const hydrated = useAppStore((s) => s.hydrated)
const settings = useAppStore((s) => s.state?.settings)
const [mobileNavOpen, setMobileNavOpen] = useState(false) const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [whatsNew, setWhatsNew] = useState<{
open: boolean
versions: string[]
}>({ open: false, versions: [] })
useEffect(() => { useEffect(() => {
if (backendSubscribed) return undefined
backendSubscribed = true
const unsub = subscribeToBackend() const unsub = subscribeToBackend()
return unsub return () => {
backendSubscribed = false
unsub()
}
}, []) }, [])
// Различаем три кейса по `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) {
if (isExistingUser) {
// Обновляющийся — показываем заметки текущей версии.
setWhatsNew({ open: true, versions: [current] })
} else {
// Новый — тихо записываем, не отвлекаем.
window.api.updateSettings({ lastSeenVersion: current })
}
return
}
if (last !== current) {
const versions = unseenVersions(current, last)
if (versions.length > 0) {
setWhatsNew({ open: true, versions })
} else {
window.api.updateSettings({ lastSeenVersion: current })
}
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hydrated])
function closeWhatsNew(): void {
setWhatsNew({ open: false, versions: [] })
// Записываем «видел» только после закрытия — если пользователь убил
// окно процессом до клика, при следующем запуске покажется снова.
void window.api.getAppVersion().then((current) => {
window.api.updateSettings({ lastSeenVersion: current })
})
}
return ( return (
<HashRouter> <ErrorBoundary>
<div className="h-screen w-screen flex flex-col bg-bg"> <HashRouter>
<Titlebar onMenuClick={() => setMobileNavOpen(true)} /> <div className="h-screen w-screen flex flex-col bg-bg">
<div className="flex-1 flex overflow-hidden"> <Titlebar onMenuClick={() => setMobileNavOpen(true)} />
<Sidebar <div className="flex-1 flex overflow-hidden">
mobileOpen={mobileNavOpen} <Sidebar
onMobileClose={() => setMobileNavOpen(false)} mobileOpen={mobileNavOpen}
onMobileClose={() => setMobileNavOpen(false)}
/>
<main className="flex-1 overflow-hidden min-w-0">
{hydrated ? (
<ErrorBoundary>
<RoutedPages onNav={() => setMobileNavOpen(false)} />
</ErrorBoundary>
) : (
// 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>
<WhatsNewModal
open={whatsNew.open}
versions={whatsNew.versions}
onClose={closeWhatsNew}
/> />
<main className="flex-1 overflow-hidden min-w-0">
{hydrated ? (
<RoutedPages onNav={() => setMobileNavOpen(false)} />
) : (
<div className="p-8 text-text/45">Загрузка</div>
)}
</main>
</div> </div>
</div> </HashRouter>
</HashRouter> </ErrorBoundary>
) )
} }

View File

@@ -20,6 +20,7 @@ import type {
import { statLabel } from '@shared/types' import { statLabel } from '@shared/types'
import { Icon } from './lib/icon' import { Icon } from './lib/icon'
import { formatInterval } from './lib/format' import { formatInterval } from './lib/format'
import { speak } from './lib/tts'
import { translate, translateN } from './i18n' import { translate, translateN } from './i18n'
type Mode = type Mode =
@@ -27,10 +28,21 @@ type Mode =
| { kind: 'exercise'; exercise: Exercise } | { kind: 'exercise'; exercise: Exercise }
| { kind: 'match'; summary: MatchSummary; done: Set<string> } | { 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 { export default function ReminderApp(): JSX.Element {
const [mode, setMode] = useState<Mode>({ kind: 'idle' }) const [mode, setMode] = useState<Mode>({ kind: 'idle' })
const [settings, setSettings] = useState<Settings | null>(null) const [settings, setSettings] = useState<Settings | null>(null)
const settingsRef = useRef<Settings | null>(null) const settingsRef = useRef<Settings | null>(null)
// ChallengeId'ы, для которых уже отправили markChallengeDone IPC. ref,
// не state — нужен только для дедупа rapid double-click. Сбрасывается
// когда приходит новый match summary (см. onMatchEnd ниже).
const sentChallengesRef = useRef<Set<string>>(new Set())
useEffect(() => { useEffect(() => {
settingsRef.current = settings settingsRef.current = settings
@@ -41,11 +53,37 @@ export default function ReminderApp(): JSX.Element {
const u0 = window.api.onStateChanged((s) => setSettings(s.settings)) const u0 = window.api.onStateChanged((s) => setSettings(s.settings))
const u1 = window.api.onFire((ex) => { const u1 = window.api.onFire((ex) => {
setMode({ kind: 'exercise', exercise: ex }) setMode({ kind: 'exercise', exercise: ex })
if (settingsRef.current?.soundEnabled) playBeep() const s = settingsRef.current
if (s?.soundEnabled) playBeep()
if (s?.voicePromptsEnabled) {
// Задержка 800ms даёт пользователю шанс decrement'нуть stepper до
// фактического количества — TTS прозвучит уже под реальную цифру,
// если успел нажать -. Иначе скажет планируемые reps.
const lang = s.language ?? 'ru'
setTimeout(() => {
const phrase =
lang === 'ru'
? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}`
: `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}`
speak(phrase, lang)
}, 800)
}
}) })
const u2 = window.api.onMatchEnd((summary) => { const u2 = window.api.onMatchEnd((summary) => {
// Новый матч — сбрасываем дедуп challenge'ей.
sentChallengesRef.current = new Set()
setMode({ kind: 'match', summary, done: new Set() }) setMode({ kind: 'match', summary, done: new Set() })
if (settingsRef.current?.soundEnabled) playBeep() const s = settingsRef.current
if (s?.soundEnabled) playBeep()
if (s?.voicePromptsEnabled) {
const total = summary.results.reduce((acc, r) => acc + r.reps, 0)
const lang = s.language ?? 'ru'
const phrase =
lang === 'ru'
? `Матч завершён. ${total} ${repWordRu(total)} ждут.`
: `Match complete. ${total} ${total === 1 ? 'rep' : 'reps'} await.`
speak(phrase, lang)
}
}) })
return () => { return () => {
u0() u0()
@@ -54,26 +92,39 @@ export default function ReminderApp(): JSX.Element {
} }
}, []) }, [])
// ESC closes the match summary view too — keyboard parity with exercise mode.
useEffect(() => { useEffect(() => {
if (mode.kind !== 'exercise') return if (mode.kind !== 'match') return
const ex = mode.exercise
const snoozeMin = settings?.snoozeMinutes ?? 5
function onKey(e: KeyboardEvent): void { function onKey(e: KeyboardEvent): void {
if (e.key === 'Enter') { if (e.key === 'Escape') close()
window.api.markDone(ex.id).then(close)
} else if (e.key === ' ' || e.code === 'Space') {
e.preventDefault()
window.api.snooze(ex.id, snoozeMin).then(close)
} else if (e.key === 'Escape') {
window.api.skip(ex.id).then(close)
}
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, settings?.snoozeMinutes]) }, [mode.kind])
function close(): void { function close(): void {
// Если в Match Summary остались незакрытые челленджи — подтверждаем,
// чтобы пользователь не «пролетел» окно по привычке и не потерял
// мотивацию (и метрики стрик/today_done).
if (mode.kind === 'match') {
const remaining = mode.summary.results.filter(
(r) => !mode.done.has(r.challengeId)
)
if (remaining.length > 0) {
const lang = settings?.language ?? 'ru'
const reps = remaining.reduce((s, r) => s + r.reps, 0)
const msg =
lang === 'ru'
? `Осталось ${remaining.length} ${
remaining.length === 1 ? 'челлендж' : 'челленджей'
} · ${reps} повторений. Закрыть окно?`
: `${remaining.length} ${
remaining.length === 1 ? 'challenge' : 'challenges'
} left · ${reps} reps. Close anyway?`
if (!nativeConfirm(msg)) return
}
}
setMode({ kind: 'idle' }) setMode({ kind: 'idle' })
window.api.reminderClose() window.api.reminderClose()
} }
@@ -83,7 +134,10 @@ export default function ReminderApp(): JSX.Element {
if (mode.kind === 'idle') return <div className="reminder-shell" /> if (mode.kind === 'idle') return <div className="reminder-shell" />
if (mode.kind === 'exercise') { if (mode.kind === 'exercise') {
return ( return (
// key={exercise.id} forces a fresh component (and fresh stepper state)
// when a new reminder arrives while the previous modal is still open.
<ExerciseReminder <ExerciseReminder
key={mode.exercise.id + ':' + mode.exercise.nextFireAt}
exercise={mode.exercise} exercise={mode.exercise}
snoozeMinutes={settings?.snoozeMinutes ?? 5} snoozeMinutes={settings?.snoozeMinutes ?? 5}
lang={lang} lang={lang}
@@ -96,13 +150,29 @@ export default function ReminderApp(): JSX.Element {
summary={mode.summary} summary={mode.summary}
done={mode.done} done={mode.done}
lang={lang} lang={lang}
onMarkDone={(id) => onMarkDone={(id) => {
setMode({ // Дедупликация: rapid double-click может два раза вызвать
kind: 'match', // onMarkDone до того как `disabled={done}` доедет до DOM.
summary: mode.summary, // Раньше это писало в историю дважды → лишние +N reps.
done: new Set([...mode.done, id]) 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'
? {
kind: 'match',
summary: m.summary,
done: new Set([...m.done, id])
}
: m
)
}}
onClose={close} onClose={close}
/> />
) )
@@ -124,14 +194,13 @@ function ExerciseReminder({
const [actualReps, setActualReps] = useState(exercise.reps) const [actualReps, setActualReps] = useState(exercise.reps)
const adjusted = actualReps !== exercise.reps const adjusted = actualReps !== exercise.reps
// Cap the stepper at 5× planned so a stuck "+" button can't log nonsense.
const REP_CAP = Math.max(50, exercise.reps * 5)
async function done(): Promise<void> { async function done(): Promise<void> {
// Only pass actualReps when user adjusted — otherwise leave undefined // Only pass actualReps when user adjusted — otherwise leave undefined
// so history records the full planned value cleanly. // so history records the full planned value cleanly.
await window.api.markDone( await window.api.markDone(exercise.id, adjusted ? actualReps : undefined)
exercise.id,
adjusted ? actualReps : undefined
)
onClose() onClose()
} }
async function snooze(): Promise<void> { async function snooze(): Promise<void> {
@@ -143,7 +212,38 @@ function ExerciseReminder({
onClose() onClose()
} }
const dec = (): void => setActualReps((n) => Math.max(0, n - 1)) const dec = (): void => setActualReps((n) => Math.max(0, n - 1))
const inc = (): void => setActualReps((n) => n + 1) const inc = (): void => setActualReps((n) => Math.min(REP_CAP, n + 1))
// Keyboard shortcuts live INSIDE the component so they have access to the
// current `actualReps` — pressing Enter respects the stepper's adjustment.
useEffect(() => {
function onKey(e: KeyboardEvent): void {
// Don't hijack Space when a button is focused (default activation).
const targetTag = (e.target as HTMLElement | null)?.tagName
if (e.key === 'Enter') {
e.preventDefault()
void done()
} else if (
(e.key === ' ' || e.code === 'Space') &&
targetTag !== 'BUTTON'
) {
e.preventDefault()
void snooze()
} else if (e.key === 'Escape') {
e.preventDefault()
void skip()
} else if (e.key === 'ArrowUp' || e.key === '+') {
e.preventDefault()
inc()
} else if (e.key === 'ArrowDown' || e.key === '-') {
e.preventDefault()
dec()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actualReps, snoozeMinutes])
return ( return (
<div className="reminder-shell flex flex-col h-full"> <div className="reminder-shell flex flex-col h-full">
@@ -170,7 +270,7 @@ function ExerciseReminder({
</motion.div> </motion.div>
<div className="text-[13px] uppercase tracking-[0.18em] text-accent font-bold"> <div className="text-[13px] uppercase tracking-[0.18em] text-accent font-bold">
{t('reminder.kicker')} {t(`category.${exercise.category ?? 'exercise'}.cta`)}
</div> </div>
<h1 className="font-serif text-[30px] leading-tight tracking-tight mt-2 mb-3 font-bold"> <h1 className="font-serif text-[30px] leading-tight tracking-tight mt-2 mb-3 font-bold">
{exercise.name} {exercise.name}
@@ -181,7 +281,7 @@ function ExerciseReminder({
<button <button
onClick={dec} onClick={dec}
className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all" className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all"
aria-label="" aria-label={t('reminder.aria.decrement')}
> >
<Minus size={16} strokeWidth={2.5} /> <Minus size={16} strokeWidth={2.5} />
</button> </button>
@@ -201,14 +301,17 @@ function ExerciseReminder({
<button <button
onClick={inc} onClick={inc}
className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all" className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all"
aria-label="+" aria-label={t('reminder.aria.increment')}
> >
<Plus size={16} strokeWidth={2.5} /> <Plus size={16} strokeWidth={2.5} />
</button> </button>
</div> </div>
{adjusted && ( {adjusted && (
<div className="text-[12px] text-accent mt-2 font-medium"> <div className="text-[12px] text-accent mt-2 font-medium">
{t('reminder.partial', { actual: actualReps, planned: exercise.reps })} {t('reminder.partial', {
actual: actualReps,
planned: exercise.reps
})}
</div> </div>
)} )}
@@ -320,7 +423,8 @@ function MatchSummaryView({
<p className="text-[13px] text-text/65 mt-1.5 font-medium"> <p className="text-[13px] text-text/65 mt-1.5 font-medium">
<span className="font-mono-num font-bold text-text">{minutes}</span>{' '} <span className="font-mono-num font-bold text-text">{minutes}</span>{' '}
{t('fmt.m')} ·{' '} {t('fmt.m')} ·{' '}
{tn('match.summary.challenges', summary.results.length)}{' · '} {tn('match.summary.challenges', summary.results.length)}
{' · '}
{allDone ? ( {allDone ? (
<span className="text-success font-bold"> <span className="text-success font-bold">
{t('match.summary.all_done')} {t('match.summary.all_done')}
@@ -443,6 +547,18 @@ function ChallengeRow({
) )
} }
/**
* CLDR-минимум для русского склонения «раз». 1 раз / 2 раза / 5 раз.
* Не тащим сюда полную плюрализацию из i18n — это TTS-only фраза.
*/
function repWordRu(n: number): string {
const m10 = Math.abs(n) % 10
const m100 = Math.abs(n) % 100
if (m10 === 1 && m100 !== 11) return 'раз'
if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return 'раза'
return 'раз'
}
function playBeep(): void { function playBeep(): void {
try { try {
const Ctx = const Ctx =

View File

@@ -0,0 +1,217 @@
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 {
computeAchievements,
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,
Flame,
Sparkles,
TrendingUp
} as const
type Props = {
history: HistoryEntry[]
exercises: Exercise[]
}
/**
* Сетка достижений. Показывает: (1) все unlocked прямо, (2) первое
* unlocked-в-прогрессе (ближайшее по %% — мотивация), (3) остальные
* как блёклые «locked». В компактной grid 4-в-ряд.
*/
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»
// оказалось вверху, а «осталось 9999» внизу.
const nearestLocked = [...locked].sort((a, b) => {
const ap = a.current / a.target
const bp = b.current / b.target
return bp - ap
})
// Показываем: все unlocked + first 2 nearest locked (preview-мотивация).
const visible = [...unlocked, ...nearestLocked.slice(0, 2)]
if (visible.length === 0) return <></>
return (
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-accent text-white grid place-items-center">
<Award size={14} strokeWidth={2.6} />
</div>
<div className="text-[14px] text-text/75 font-semibold">
{t('achievements.title')}
</div>
</div>
<div className="text-[12px] text-text/55 font-mono-num font-medium">
{t('achievements.unlocked_of', {
n: unlocked.length,
total: achievements.length
})}
</div>
</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}
fresh={freshlyUnlocked.has(a.def.id)}
/>
))}
</div>
</div>
)
}
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))
const toneBg = {
accent: 'bg-accent',
warning: 'bg-warning',
success: 'bg-success',
info: 'bg-info'
}[a.def.tone]
return (
<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'
].join(' ')}
title={t(a.def.descKey, { target: a.target })}
>
<div className="flex items-center gap-2 mb-1.5">
<div
className={[
'w-7 h-7 rounded-lg grid place-items-center text-white shrink-0',
a.unlocked ? toneBg : 'bg-text/30'
].join(' ')}
>
{a.unlocked ? (
<IconCmp size={14} strokeWidth={2.4} />
) : (
<Lock size={12} strokeWidth={2.4} />
)}
</div>
<div className="text-[12px] font-semibold truncate">
{t(a.def.titleKey)}
</div>
</div>
{!a.unlocked && (
<>
<div className="h-1 rounded-full bg-text/10 overflow-hidden">
<div
className={['h-full', toneBg].join(' ')}
style={{ width: `${pct}%` }}
/>
</div>
<div className="text-[10px] text-text/55 mt-1 font-mono-num font-medium">
{t('achievements.progress', { n: a.target - a.current })}
</div>
</>
)}
</motion.div>
)
}

View File

@@ -0,0 +1,61 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
type Props = {
children: ReactNode
/** Optional render override; receives the captured error. */
fallback?: (err: Error, reset: () => void) => ReactNode
}
type State = {
error: Error | null
}
/**
* Top-level error boundary so a crash in one subtree (e.g. a malformed
* history entry crashing HistoryHeatmap) does not blank the whole window.
* React class components are still the only way to implement this.
*/
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// No remote telemetry — log to the local console so a curious user
// (or dev tools session) can capture it.
console.error('[ErrorBoundary]', error, info.componentStack)
}
reset = (): void => this.setState({ error: null })
render(): ReactNode {
const { error } = this.state
if (!error) return this.props.children
if (this.props.fallback) return this.props.fallback(error, this.reset)
return (
<div className="p-6 max-w-xl mx-auto text-center">
<div className="text-[15px] font-semibold mb-2">
Что-то пошло не так
</div>
<div className="text-[13px] text-text/65 mb-4 break-words">
{error.message}
</div>
<button
onClick={this.reset}
className="h-9 px-4 rounded-xl bg-accent text-white text-[14px] font-semibold active:scale-95 transition-transform"
>
Попробовать снова
</button>
{import.meta.env.DEV && error.stack && (
<pre className="mt-6 p-3 bg-surface-2 rounded-xl text-left text-[11px] font-mono-num overflow-auto max-h-64">
{error.stack}
</pre>
)}
</div>
)
}
}

View File

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

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { Exercise } from '@shared/types' import type { Exercise, ReminderCategory } from '@shared/types'
import { REMINDER_CATEGORIES } from '@shared/types'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import { Button } from './ui/Button' import { Button } from './ui/Button'
import { ICON_CHOICES, Icon } from '../lib/icon' import { ICON_CHOICES, Icon } from '../lib/icon'
@@ -11,6 +12,10 @@ type Draft = {
icon: string icon: string
intervalMinutes: number intervalMinutes: number
enabled: boolean enabled: boolean
category: ReminderCategory
/** undefined = без дневной цели (только interval). */
dailyGoal?: number
adaptive?: boolean
} }
const EMPTY: Draft = { const EMPTY: Draft = {
@@ -18,7 +23,10 @@ const EMPTY: Draft = {
reps: 10, reps: 10,
icon: 'Activity', icon: 'Activity',
intervalMinutes: 30, intervalMinutes: 30,
enabled: true enabled: true,
category: 'exercise',
dailyGoal: undefined,
adaptive: false
} }
type Props = { type Props = {
@@ -44,7 +52,10 @@ export function ExerciseEditor({
reps: exercise.reps, reps: exercise.reps,
icon: exercise.icon, icon: exercise.icon,
intervalMinutes: exercise.intervalMinutes, intervalMinutes: exercise.intervalMinutes,
enabled: exercise.enabled enabled: exercise.enabled,
category: exercise.category ?? 'exercise',
dailyGoal: exercise.dailyGoal,
adaptive: exercise.adaptive ?? false
}) })
} else { } else {
setDraft(EMPTY) setDraft(EMPTY)
@@ -101,6 +112,26 @@ export function ExerciseEditor({
/> />
</Field> </Field>
<Field label={t('editor.field.category')}>
<div className="grid grid-cols-4 gap-2">
{REMINDER_CATEGORIES.map((c) => (
<button
key={c}
type="button"
onClick={() => setDraft({ ...draft, category: c })}
className={[
'h-10 px-2 rounded-xl text-[13px] font-semibold transition-all active:scale-95 truncate',
draft.category === c
? 'bg-accent text-white'
: 'bg-surface-2 text-text/65 hover:text-text'
].join(' ')}
>
{t(`category.${c}`)}
</button>
))}
</div>
</Field>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label={t('editor.field.reps')}> <Field label={t('editor.field.reps')}>
<input <input
@@ -132,6 +163,58 @@ export function ExerciseEditor({
</Field> </Field>
</div> </div>
<Field label={t('editor.field.daily_goal')}>
<div className="flex items-center gap-2">
<input
type="number"
min={1}
placeholder={t('editor.field.daily_goal.placeholder')}
value={draft.dailyGoal ?? ''}
onChange={(e) => {
const v = e.target.value
if (v === '') setDraft({ ...draft, dailyGoal: undefined })
else
setDraft({
...draft,
dailyGoal: Math.max(1, Number(v) || 1)
})
}}
className="ios-input font-mono-num flex-1"
/>
{draft.dailyGoal !== undefined && (
<button
type="button"
onClick={() => setDraft({ ...draft, dailyGoal: undefined })}
className="h-9 px-3 rounded-xl bg-surface-2 text-text/65 text-[13px] font-semibold hover:text-text"
>
{t('editor.field.daily_goal.clear')}
</button>
)}
</div>
<div className="text-[12px] text-text/55 mt-1.5 leading-snug">
{t('editor.field.daily_goal.hint')}
</div>
</Field>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={draft.adaptive ?? false}
onChange={(e) =>
setDraft({ ...draft, adaptive: e.target.checked })
}
className="mt-0.5 w-4 h-4 accent-accent"
/>
<div>
<div className="text-[14px] font-semibold leading-tight">
{t('editor.field.adaptive.label')}
</div>
<div className="text-[12px] text-text/55 mt-1 leading-snug">
{t('editor.field.adaptive.hint')}
</div>
</div>
</label>
<Field label={t('editor.field.icon')}> <Field label={t('editor.field.icon')}>
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2"> <div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
{ICON_CHOICES.map((name) => ( {ICON_CHOICES.map((name) => (

View File

@@ -1,59 +1,83 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { dailyRepsRange } from '../lib/history' import { dailyRepsRange } from '../lib/history'
import type { Exercise, HistoryEntry, Language } from '@shared/types' import type { Exercise, HistoryEntry } from '@shared/types'
import { translateN, useT } from '../i18n'
type Props = { type Props = {
history: HistoryEntry[] history: HistoryEntry[]
exercises: Exercise[] exercises: Exercise[]
days?: number days?: number
lang: Language
} }
/** /**
* GitHub-style contribution grid: weeks as columns, days-of-week as rows. * GitHub-style contribution grid: weeks as columns, days-of-week as rows.
* Intensity bucket from 0 to 4 based on relative reps within the window. *
* Intensity bucket uses percentile-based thresholds (over non-zero days)
* rather than a flat ratio against the single max — so one outlier day
* doesn't blot out every normal day into the lowest bucket.
*/ */
export function HistoryHeatmap({ export function HistoryHeatmap({
history, history,
exercises, exercises,
days = 84, // 12 weeks days = 84 // 12 weeks
lang
}: Props): JSX.Element { }: Props): JSX.Element {
const { t, lang } = useT()
const cells = useMemo( const cells = useMemo(
() => dailyRepsRange(history, exercises, days), () => dailyRepsRange(history, exercises, days),
[history, exercises, days] [history, exercises, days]
) )
const max = cells.reduce((m, c) => Math.max(m, c.reps), 0)
// Bucket function — 0 for zero, 1-4 for low/med/high/peak. // Percentile-based bucket thresholds over non-zero days. Stable when the
// user has one outlier (e.g. a 200-rep "catch up" day) — normal 10-rep
// days still spread across buckets 1..4 instead of all collapsing to 1.
const thresholds = useMemo(() => {
const nz = cells
.map((c) => c.reps)
.filter((n) => n > 0)
.sort((a, b) => a - b)
if (nz.length === 0) return null
const p = (q: number): number =>
nz[Math.min(nz.length - 1, Math.floor(q * nz.length))]
return { p25: p(0.25), p50: p(0.5), p85: p(0.85) }
}, [cells])
function bucket(n: number): number { function bucket(n: number): number {
if (n === 0) return 0 if (n === 0 || !thresholds) return 0
if (max === 0) return 0 if (n <= thresholds.p25) return 1
const ratio = n / max if (n <= thresholds.p50) return 2
if (ratio < 0.25) return 1 if (n <= thresholds.p85) return 3
if (ratio < 0.5) return 2
if (ratio < 0.85) return 3
return 4 return 4
} }
// Group cells into columns (weeks). Pad start so first column aligns to // Group cells into columns (weeks). Pad start so the first column aligns
// its actual week (Mon-first). // to its actual weekday (Mon-first).
const firstDay = cells[0]?.date ?? new Date() const weeks = useMemo(() => {
const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon const firstDay = cells[0]?.date ?? new Date()
const padded: ({ const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon
key: string const padded: ({
date: Date key: string
reps: number date: Date
} | null)[] = [...Array(firstWeekday).fill(null), ...cells] reps: number
const weeks: (typeof padded)[] = [] } | null)[] = [...Array(firstWeekday).fill(null), ...cells]
for (let i = 0; i < padded.length; i += 7) { const out: (typeof padded)[] = []
weeks.push(padded.slice(i, i + 7)) for (let i = 0; i < padded.length; i += 7) {
} out.push(padded.slice(i, i + 7))
}
return out
}, [cells])
const dayLabels = // Day labels along the Y axis. Mon-first, only label every other day to
lang === 'en' // keep the column narrow. Pulled from the i18n dict (index = Date.getDay()).
? ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'] const dayLabels = [
: ['Пн', '', 'Ср', '', 'Пт', '', 'Вс'] t('weekday.short.1'), // Mon
'',
t('weekday.short.3'), // Wed
'',
t('weekday.short.5'), // Fri
'',
t('weekday.short.0') // Sun
]
const monthLabels = useMemo(() => { const monthLabels = useMemo(() => {
const fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', { const fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', {
@@ -65,7 +89,7 @@ export function HistoryHeatmap({
}) })
}, [weeks, lang]) }, [weeks, lang])
// Compress repeated month labels (only show on first week of the month) // Show a month label only on the first week that lands inside it.
const monthLabelsCompressed = monthLabels.map((label, i) => const monthLabelsCompressed = monthLabels.map((label, i) =>
label && label !== monthLabels[i - 1] ? label : '' label && label !== monthLabels[i - 1] ? label : ''
) )
@@ -79,11 +103,16 @@ export function HistoryHeatmap({
[lang] [lang]
) )
// Pluralised "{n} reps" / "{n} повторов" for the cell tooltip.
// Outside React state — needed inside the cell-render closure.
const repsLabel = (n: number): string =>
translateN(lang, 'heatmap.tooltip.reps', n)
return ( return (
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30"> <div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<div className="text-[14px] text-text/75 font-semibold"> <div className="text-[14px] text-text/75 font-semibold">
{lang === 'en' ? 'Activity, last 12 weeks' : 'Активность за 12 недель'} {t('heatmap.title')}
</div> </div>
</div> </div>
@@ -115,9 +144,7 @@ export function HistoryHeatmap({
<div key={wi} className="flex flex-col gap-[3px]"> <div key={wi} className="flex flex-col gap-[3px]">
{w.map((c, di) => { {w.map((c, di) => {
if (!c) { if (!c) {
return ( return <div key={di} className="w-[12px] h-[12px]" />
<div key={di} className="w-[12px] h-[12px]" />
)
} }
const b = bucket(c.reps) const b = bucket(c.reps)
const tone = const tone =
@@ -133,7 +160,7 @@ export function HistoryHeatmap({
return ( return (
<div <div
key={di} key={di}
title={`${dateFmt.format(c.date)} · ${c.reps} ${lang === 'en' ? 'reps' : 'повторов'}`} title={`${dateFmt.format(c.date)} · ${repsLabel(c.reps)}`}
className={[ className={[
'w-[12px] h-[12px] rounded-[3px] transition-colors', 'w-[12px] h-[12px] rounded-[3px] transition-colors',
tone tone
@@ -149,7 +176,7 @@ export function HistoryHeatmap({
{/* Legend */} {/* Legend */}
<div className="flex items-center justify-end gap-1.5 mt-3 text-[10px] text-text/45 font-medium"> <div className="flex items-center justify-end gap-1.5 mt-3 text-[10px] text-text/45 font-medium">
<span>{lang === 'en' ? 'Less' : 'Меньше'}</span> <span>{t('heatmap.legend.less')}</span>
{[0, 1, 2, 3, 4].map((b) => ( {[0, 1, 2, 3, 4].map((b) => (
<div <div
key={b} key={b}
@@ -167,7 +194,7 @@ export function HistoryHeatmap({
].join(' ')} ].join(' ')}
/> />
))} ))}
<span>{lang === 'en' ? 'More' : 'Больше'}</span> <span>{t('heatmap.legend.more')}</span>
</div> </div>
</div> </div>
) )

View File

@@ -1,13 +1,7 @@
import { useEffect, useRef } from 'react'
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { import { Sun, Dumbbell, Joystick, Flame, Settings2, X } from 'lucide-react'
Sun,
Dumbbell,
Joystick,
Flame,
Settings2,
X
} from 'lucide-react'
import { useT } from '../i18n' import { useT } from '../i18n'
type Item = { type Item = {
@@ -51,6 +45,53 @@ export function Sidebar({
onMobileClose onMobileClose
}: Props): JSX.Element { }: Props): JSX.Element {
const { t } = useT() const { t } = useT()
const drawerRef = useRef<HTMLElement | null>(null)
const lastFocusedRef = useRef<HTMLElement | null>(null)
// Esc closes + focus trap while the mobile drawer is open. Mirrors the
// pattern used in Modal.tsx.
useEffect(() => {
if (!mobileOpen) return undefined
lastFocusedRef.current = document.activeElement as HTMLElement | null
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
e.preventDefault()
onMobileClose?.()
return
}
if (e.key !== 'Tab') return
const root = drawerRef.current
if (!root) return
const focusables = root.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
if (focusables.length === 0) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
const active = document.activeElement as HTMLElement | null
if (e.shiftKey) {
if (active === first || !root.contains(active)) {
e.preventDefault()
last.focus()
}
} else {
if (active === last || !root.contains(active)) {
e.preventDefault()
first.focus()
}
}
}
document.addEventListener('keydown', onKeyDown, true)
return () => {
document.removeEventListener('keydown', onKeyDown, true)
// Return focus to the trigger (Titlebar's hamburger) so keyboard users
// pick up where they left off.
const target = lastFocusedRef.current
if (target && document.body.contains(target)) target.focus()
}
}, [mobileOpen, onMobileClose])
return ( return (
<> <>
<aside className="hidden md:flex w-64 shrink-0 vibrancy hairline-b border-r-0 flex-col"> <aside className="hidden md:flex w-64 shrink-0 vibrancy hairline-b border-r-0 flex-col">
@@ -72,8 +113,13 @@ export function Sidebar({
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
aria-hidden="true"
/> />
<motion.aside <motion.aside
ref={drawerRef}
role="dialog"
aria-modal="true"
aria-label={t('sidebar.aria.nav')}
className="relative w-72 max-w-[85vw] h-full vibrancy flex flex-col" className="relative w-72 max-w-[85vw] h-full vibrancy flex flex-col"
initial={{ x: '-100%' }} initial={{ x: '-100%' }}
animate={{ x: 0 }} animate={{ x: 0 }}

View File

@@ -1,4 +1,5 @@
import { Minus, X, Square, Menu } from 'lucide-react' import { Minus, X, Square, Copy, Menu } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useT } from '../i18n' import { useT } from '../i18n'
type Props = { type Props = {
@@ -10,8 +11,29 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
const { t } = useT() const { t } = useT()
const effectiveTitle = title ?? t('titlebar.app_title') const effectiveTitle = title ?? t('titlebar.app_title')
// Локально отслеживаем maximize-state, чтобы свапать иконку (квадрат ↔
// «двойной квадрат», как в нативной винде). Стартовое значение спрашиваем
// у main; дальше подписываемся на evtMaximizeChanged.
const [maximized, setMaximized] = useState(false)
useEffect(() => {
void window.api.isMaximizedMain().then(setMaximized)
const unsub = window.api.onMaximizeChanged((v) => setMaximized(v))
return unsub
}, [])
// Double-click по тайтлбару — стандартный Windows-жест для toggle maximize.
// Игнорируем клики по элементам с no-drag (кнопки/меню) — у них своя логика.
function onDoubleClick(e: React.MouseEvent<HTMLDivElement>): void {
const target = e.target as HTMLElement
if (target.closest('.titlebar-nodrag')) return
window.api.toggleMaximizeMain()
}
return ( return (
<div className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b"> <div
onDoubleClick={onDoubleClick}
className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b"
>
<div className="flex items-center gap-1 min-w-0 flex-1 basis-0"> <div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
{onMenuClick && ( {onMenuClick && (
<button <button
@@ -28,7 +50,10 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
{effectiveTitle} {effectiveTitle}
</div> </div>
<div className="titlebar-nodrag flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0"> {/* no-drag навешен на сами кнопки, не на обёртку: иначе из-за
flex-1 basis-0 весь кластер (включая пустое место слева от кнопок)
становится no-drag, и окно нельзя ухватить рядом с кнопками. */}
<div className="flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
<WinBtn <WinBtn
onClick={() => window.api.minimizeMain()} onClick={() => window.api.minimizeMain()}
label={t('titlebar.minimize_aria')} label={t('titlebar.minimize_aria')}
@@ -36,10 +61,18 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
<Minus size={13} strokeWidth={2} /> <Minus size={13} strokeWidth={2} />
</WinBtn> </WinBtn>
<WinBtn <WinBtn
onClick={() => window.api.hideMain()} onClick={() => window.api.toggleMaximizeMain()}
label={t('titlebar.tray_aria')} label={
maximized
? t('titlebar.restore_aria')
: t('titlebar.maximize_aria')
}
> >
<Square size={11} strokeWidth={2} /> {maximized ? (
<Copy size={11} strokeWidth={2} />
) : (
<Square size={11} strokeWidth={2} />
)}
</WinBtn> </WinBtn>
<WinBtn <WinBtn
onClick={() => window.api.closeMain()} onClick={() => window.api.closeMain()}
@@ -69,7 +102,7 @@ function WinBtn({
onClick={onClick} onClick={onClick}
aria-label={label} aria-label={label}
className={[ className={[
'w-9 h-7 grid place-items-center rounded-md transition-colors text-text/55', 'titlebar-nodrag w-9 h-7 grid place-items-center rounded-md transition-colors text-text/55',
danger danger
? 'hover:bg-destructive hover:text-white' ? 'hover:bg-destructive hover:text-white'
: 'hover:bg-text/[0.08] hover:text-text' : 'hover:bg-text/[0.08] hover:text-text'

View File

@@ -10,11 +10,23 @@ import {
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Button } from './ui/Button' import { Button } from './ui/Button'
import { Card } from './ui/Card' import { Card } from './ui/Card'
import { useT } from '../i18n' import { useT, type TFn } from '../i18n'
import type { UpdaterStatus } from '@shared/types' import type { UpdaterStatus } from '@shared/types'
function formatChecked(ts: number, t: TFn): string {
const diffMs = Date.now() - ts
const diffMin = Math.max(0, Math.round(diffMs / 60_000))
if (diffMin < 1) return t('updater.checked.just_now')
if (diffMin < 60) return t('updater.checked.minutes_ago', { n: diffMin })
const diffH = Math.round(diffMin / 60)
return t('updater.checked.hours_ago', { n: diffH })
}
export function UpdaterCard(): JSX.Element { export function UpdaterCard(): JSX.Element {
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' }) const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
// busy используется только для синхронного `check()` — для асинхронного
// download/install статус сам переключится через события (downloading →
// downloaded), отдельный busy-флаг будет только дублировать визуально.
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
useEffect(() => { useEffect(() => {
@@ -30,16 +42,15 @@ export function UpdaterCard(): JSX.Element {
setBusy(false) setBusy(false)
} }
} }
async function download(): Promise<void> { function download(): void {
setBusy(true) // Fire-and-forget — UI моментально перейдёт в kind:'downloading' через
try { // первое же event'ное обновление статуса. Никакого `await` — пользователь
await window.api.updaterDownload() // должен иметь возможность уйти на Dashboard, продолжать упражнения,
} finally { // пока обновление качается в фоне.
setBusy(false) window.api.updaterDownload()
}
} }
function install(): void { function install(): void {
void window.api.updaterInstall() window.api.updaterInstall()
} }
return ( return (
@@ -85,23 +96,25 @@ function Body({
<Cell <Cell
tone="info" tone="info"
icon={ icon={
<RefreshCw <RefreshCw size={16} strokeWidth={2.4} className="animate-spin" />
size={16}
strokeWidth={2.4}
className="animate-spin"
/>
} }
title={t('updater.checking')} title={t('updater.checking')}
/> />
) )
} }
if (status.kind === 'not-available') { if (status.kind === 'not-available') {
const subtitle = status.lastCheckedAt
? t('updater.up_to_date.subtitle_checked', {
v: status.currentVersion,
when: formatChecked(status.lastCheckedAt, t)
})
: t('updater.up_to_date.subtitle', { v: status.currentVersion })
return ( return (
<Cell <Cell
tone="success" tone="success"
icon={<CheckCircle2 size={16} strokeWidth={2.4} />} icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
title={t('updater.up_to_date')} title={t('updater.up_to_date')}
subtitle={t('updater.up_to_date.subtitle', { v: status.currentVersion })} subtitle={subtitle}
action={ action={
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}> <Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')} <RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')}
@@ -130,8 +143,16 @@ function Body({
) )
} }
if (status.kind === 'downloading') { if (status.kind === 'downloading') {
const pct = Math.max(0, Math.min(100, status.percent || 0)) // electron-updater fires early `download-progress` events where some
const mb = (n: number): string => (n / 1024 / 1024).toFixed(1) // fields are undefined; guard against NaN/Infinity so we never render
// "NaN%" or "NaN MB/s".
const rawPct = Number.isFinite(status.percent) ? status.percent : 0
const pct = Math.max(0, Math.min(100, rawPct))
const mb = (n: number): string =>
Number.isFinite(n) ? (n / 1024 / 1024).toFixed(1) : '0.0'
const speed = Number.isFinite(status.bytesPerSecond)
? (status.bytesPerSecond / 1024 / 1024).toFixed(2)
: '0.00'
return ( return (
<div className="px-4 py-4"> <div className="px-4 py-4">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
@@ -146,7 +167,7 @@ function Body({
{t('updater.downloading.subtitle', { {t('updater.downloading.subtitle', {
got: mb(status.transferred), got: mb(status.transferred),
total: mb(status.total), total: mb(status.total),
speed: (status.bytesPerSecond / 1024 / 1024).toFixed(2) speed
})} })}
</div> </div>
</div> </div>
@@ -161,6 +182,10 @@ function Body({
transition={{ duration: 0.3, ease: 'linear' }} transition={{ duration: 0.3, ease: 'linear' }}
/> />
</div> </div>
{/* Подсказка: download идёт в фоне, не нужно сидеть на этом экране. */}
<div className="text-[12px] text-text/55 mt-3 font-medium">
{t('updater.downloading.hint')}
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,108 @@
import { useMemo } from 'react'
import { Sparkles, Wrench, Shield, Gauge } from 'lucide-react'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { useT } from '../i18n'
import { RELEASE_NOTES } from '@shared/release-notes'
import type { ReleaseNoteItem } from '@shared/release-notes'
import type { Language } from '@shared/types'
type Props = {
open: boolean
versions: string[]
onClose: () => void
}
const TAG_META = {
new: { icon: Sparkles, cls: 'bg-accent text-white' },
fix: { icon: Wrench, cls: 'bg-info text-white' },
security: { icon: Shield, cls: 'bg-warning text-white' },
perf: { icon: Gauge, cls: 'bg-success text-white' }
} as const
/**
* Показывает заметки релизов для одной или нескольких версий. Используется
* (a) автоматически после апдейта (когда `lastSeenVersion` != `currentVersion`)
* и (b) вручную из Settings.
*/
export function WhatsNewModal({
open,
versions,
onClose
}: Props): JSX.Element {
const { t, lang } = useT()
return (
<Modal
open={open}
onClose={onClose}
title={t('whatsnew.title')}
size="md"
footer={<Button onClick={onClose}>{t('whatsnew.btn.close')}</Button>}
>
<div className="space-y-6">
{versions.length === 0 && (
<div className="text-[14px] text-text/65 font-medium py-2">
{t('whatsnew.empty')}
</div>
)}
{versions.map((v) => (
<VersionSection key={v} version={v} lang={lang} />
))}
</div>
</Modal>
)
}
function VersionSection({
version,
lang
}: {
version: string
lang: Language
}): JSX.Element {
const items = useMemo<ReleaseNoteItem[]>(() => {
const notes = RELEASE_NOTES[version]
if (!notes) return []
return notes[lang] ?? notes.ru
}, [version, lang])
return (
<div>
<div className="text-[12px] uppercase tracking-wider text-text/55 font-bold mb-3 font-mono-num">
v{version}
</div>
<div className="space-y-2.5">
{items.map((it, i) => (
<NoteRow key={i} item={it} />
))}
</div>
</div>
)
}
function NoteRow({ item }: { item: ReleaseNoteItem }): JSX.Element {
const meta = TAG_META[item.tag ?? 'new']
const IconCmp = meta.icon
return (
<div className="flex items-start gap-3">
<div
className={[
'w-8 h-8 rounded-lg grid place-items-center shrink-0 mt-0.5',
meta.cls
].join(' ')}
>
<IconCmp size={15} strokeWidth={2.4} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[14px] font-semibold leading-snug">
{item.title}
</div>
{item.detail && (
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{item.detail}
</div>
)}
</div>
</div>
)
}

View File

@@ -24,15 +24,13 @@ const legacyMap: Record<LegacyVariant, Variant> = {
} }
const variantClasses: Record<Variant, string> = { const variantClasses: Record<Variant, string> = {
filled: filled: 'bg-accent text-white hover:brightness-105 active:brightness-95',
'bg-accent text-white hover:brightness-105 active:brightness-95',
tinted: tinted:
'bg-accent/12 text-accent hover:bg-accent/18 active:bg-accent/22 dark:bg-accent/20 dark:hover:bg-accent/25', 'bg-accent/12 text-accent hover:bg-accent/18 active:bg-accent/22 dark:bg-accent/20 dark:hover:bg-accent/25',
plain: 'text-accent hover:bg-accent/10 active:bg-accent/15', plain: 'text-accent hover:bg-accent/10 active:bg-accent/15',
destructive: destructive:
'bg-destructive/12 text-destructive hover:bg-destructive/18 active:bg-destructive/22 dark:bg-destructive/20', 'bg-destructive/12 text-destructive hover:bg-destructive/18 active:bg-destructive/22 dark:bg-destructive/20',
success: success: 'bg-success text-white hover:brightness-105 active:brightness-95'
'bg-success text-white hover:brightness-105 active:brightness-95'
} }
const sizeClasses: Record<Size, string> = { const sizeClasses: Record<Size, string> = {

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

@@ -1,6 +1,7 @@
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ReactNode, useEffect } from 'react' import { ReactNode, useEffect, useId, useRef } from 'react'
import { useT } from '../../i18n'
type Props = { type Props = {
open: boolean open: boolean
@@ -17,9 +18,25 @@ const sizeClass = {
lg: 'max-w-3xl' lg: 'max-w-3xl'
} }
/** All elements inside `root` that can receive keyboard focus. */
function getFocusable(root: HTMLElement): HTMLElement[] {
return Array.from(
root.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.offsetParent !== null || el === document.activeElement)
}
/** /**
* iOS-style centred sheet. Spring-snap on enter, soft fade-out. * iOS-style centred sheet. Spring-snap on enter, soft fade-out.
* Backdrop uses heavy blur for proper iOS modal feel. *
* Accessibility:
* - role="dialog" + aria-modal="true" + aria-labelledby on the title <h2>
* - Focus is trapped inside the dialog while open; Tab/Shift-Tab cycle
* through focusable children and never escape to the underlying page.
* - On open the first focusable element is focused.
* - On close, focus returns to whatever was focused when the modal opened.
* - Esc closes (parent handles confirm-on-dirty if it wants).
*/ */
export function Modal({ export function Modal({
open, open,
@@ -29,6 +46,12 @@ export function Modal({
footer, footer,
size = 'md' size = 'md'
}: Props): JSX.Element { }: Props): JSX.Element {
const { t } = useT()
const titleId = useId()
const sheetRef = useRef<HTMLDivElement | null>(null)
const lastFocusedRef = useRef<HTMLElement | null>(null)
// Esc closes.
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
@@ -38,6 +61,60 @@ export function Modal({
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [open, onClose]) }, [open, onClose])
// Focus trap + focus restore.
useEffect(() => {
if (!open) return
const previouslyFocused = document.activeElement as HTMLElement | null
lastFocusedRef.current = previouslyFocused
// Defer focus to the next frame — framer-motion's enter animation may
// still be mounting children when this effect runs.
const raf = requestAnimationFrame(() => {
const root = sheetRef.current
if (!root) return
const focusables = getFocusable(root)
const first = focusables.find(
(el) => !el.hasAttribute('data-modal-close')
)
;(first ?? focusables[0])?.focus()
})
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key !== 'Tab') return
const root = sheetRef.current
if (!root) return
const focusables = getFocusable(root)
if (focusables.length === 0) {
e.preventDefault()
return
}
const first = focusables[0]
const last = focusables[focusables.length - 1]
const active = document.activeElement as HTMLElement | null
if (e.shiftKey) {
if (active === first || !root.contains(active)) {
e.preventDefault()
last.focus()
}
} else {
if (active === last || !root.contains(active)) {
e.preventDefault()
first.focus()
}
}
}
document.addEventListener('keydown', onKeyDown, true)
return () => {
cancelAnimationFrame(raf)
document.removeEventListener('keydown', onKeyDown, true)
// Restore focus to the trigger (button/row) that opened the modal,
// unless it was unmounted while the modal was open.
const target = lastFocusedRef.current
if (target && document.body.contains(target)) target.focus()
}
}, [open])
return ( return (
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
@@ -50,8 +127,10 @@ export function Modal({
onClick={onClose} onClick={onClose}
> >
<motion.div <motion.div
ref={sheetRef}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId}
className={[ className={[
'relative w-full bg-surface rounded-3xl shadow-sheet flex flex-col overflow-hidden', 'relative w-full bg-surface rounded-3xl shadow-sheet flex flex-col overflow-hidden',
sizeClass[size] sizeClass[size]
@@ -64,13 +143,17 @@ export function Modal({
> >
{/* Header — iOS large modal title */} {/* Header — iOS large modal title */}
<div className="flex items-center justify-between px-5 pt-5 pb-3"> <div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="font-display text-[20px] font-semibold tracking-tight"> <h2
id={titleId}
className="font-display text-[20px] font-semibold tracking-tight"
>
{title} {title}
</h2> </h2>
<button <button
onClick={onClose} onClick={onClose}
data-modal-close=""
className="w-7 h-7 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 hover:text-text transition-colors active:scale-90" className="w-7 h-7 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 hover:text-text transition-colors active:scale-90"
aria-label="Закрыть" aria-label={t('btn.close')}
> >
<X size={14} strokeWidth={2.5} /> <X size={14} strokeWidth={2.5} />
</button> </button>

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

@@ -21,6 +21,8 @@ export const ru: Dict = {
'sidebar.status_tracking': 'Активность отслеживается', 'sidebar.status_tracking': 'Активность отслеживается',
'titlebar.menu_aria': 'Меню', 'titlebar.menu_aria': 'Меню',
'titlebar.minimize_aria': 'Свернуть', 'titlebar.minimize_aria': 'Свернуть',
'titlebar.maximize_aria': 'Развернуть',
'titlebar.restore_aria': 'Восстановить размер',
'titlebar.tray_aria': 'В трей', 'titlebar.tray_aria': 'В трей',
'titlebar.close_aria': 'Закрыть', 'titlebar.close_aria': 'Закрыть',
'titlebar.app_title': 'Exercise Reminder', 'titlebar.app_title': 'Exercise Reminder',
@@ -29,6 +31,15 @@ export const ru: Dict = {
'btn.add': 'Добавить', 'btn.add': 'Добавить',
'btn.new': 'Новый', 'btn.new': 'Новый',
'btn.cancel': 'Отмена', 'btn.cancel': 'Отмена',
'btn.ok': 'OK',
'btn.delete.confirm': 'Удалить',
'exercise.delete.title': 'Удалить упражнение?',
'exercise.delete.body':
'Упражнение «{name}» будет удалено. История останется, но в ней останется только название.',
'exercise.goal_reached.kicker': 'Цель закрыта',
'exercise.goal_reached.value': '{done}/{goal}',
'exercise.adaptive.badge': 'адаптивный режим',
'settings.data.import.modal.title': 'Восстановить из файла?',
'btn.save': 'Сохранить', 'btn.save': 'Сохранить',
'btn.done': 'Готово', 'btn.done': 'Готово',
'btn.start': 'Старт', 'btn.start': 'Старт',
@@ -63,10 +74,16 @@ export const ru: Dict = {
'dashboard.stat.tracking': 'Трекинг матчей', 'dashboard.stat.tracking': 'Трекинг матчей',
'dashboard.stat.tracking.on': 'On', 'dashboard.stat.tracking.on': 'On',
'dashboard.stat.tracking.off': 'Off', 'dashboard.stat.tracking.off': 'Off',
'dashboard.stat.tracking.pending': 'Setup',
'dashboard.stat.tracking.subtitle_on': 'в реальном времени', 'dashboard.stat.tracking.subtitle_on': 'в реальном времени',
'dashboard.stat.tracking.subtitle_off': 'выключен', 'dashboard.stat.tracking.subtitle_off': 'выключен',
'dashboard.stat.tracking.subtitle_pending':
'нужно закрыть Steam и снова открыть',
'dashboard.paused.title': 'Напоминания на паузе', 'dashboard.paused.title': 'Напоминания на паузе',
'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт', 'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт',
'dashboard.meeting.title': 'Не дёргаем — ты на встрече',
'dashboard.meeting.hint':
'Запущен Zoom / Teams / Discord / Webex / Slack-huddle. Напоминания возобновятся когда закроешь.',
'dashboard.empty.title': 'Программа пуста', 'dashboard.empty.title': 'Программа пуста',
'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать', 'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать',
@@ -116,8 +133,7 @@ export const ru: Dict = {
// Games // Games
'games.kicker': 'Трекинг матчей', 'games.kicker': 'Трекинг матчей',
'games.title': 'Игры', 'games.title': 'Игры',
'games.subtitle': 'games.subtitle': 'Подключи игру — челленджи сработают сразу после матча',
'Подключи игру — челленджи сработают сразу после матча',
'games.subtitle.live': '{n} live', 'games.subtitle.live': '{n} live',
'games.section.supported': 'Поддерживаемые', 'games.section.supported': 'Поддерживаемые',
'games.scanning': 'Сканируем установленные игры…', 'games.scanning': 'Сканируем установленные игры…',
@@ -142,6 +158,30 @@ export const ru: Dict = {
'settings.section.appearance': 'Внешний вид', 'settings.section.appearance': 'Внешний вид',
'settings.section.language': 'Язык', 'settings.section.language': 'Язык',
'settings.section.updates': 'Обновления', 'settings.section.updates': 'Обновления',
'settings.section.data': 'Данные',
'settings.data.export.label': 'Экспортировать всё',
'settings.data.export.hint':
'Сохрани резервную копию упражнений, истории, челленджей и настроек в JSON-файл.',
'settings.data.export.btn': 'Сохранить',
'settings.data.export.ok': 'Сохранено в {path}',
'settings.data.export.err': 'Не удалось сохранить',
'settings.data.import.label': 'Восстановить из файла',
'settings.data.import.hint':
'Загрузить ранее сохранённую копию. Текущие данные будут перезаписаны.',
'settings.data.import.btn': 'Восстановить',
'settings.data.import.confirm':
'Все текущие упражнения, история и настройки будут заменены содержимым файла. Продолжить?',
'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': 'Открыть',
'whatsnew.title': 'Что нового',
'whatsnew.btn.close': 'Понятно',
'whatsnew.empty': 'Для этой версии заметок пока нет.',
'settings.notification_mode.label': 'Режим уведомления', 'settings.notification_mode.label': 'Режим уведомления',
'settings.notification_mode.hint': 'Как должно выглядеть напоминание', 'settings.notification_mode.hint': 'Как должно выглядеть напоминание',
'settings.notification_mode.modal': 'Окно поверх всех', 'settings.notification_mode.modal': 'Окно поверх всех',
@@ -149,6 +189,12 @@ export const ru: Dict = {
'settings.notification_mode.both': 'Окно и уведомление', 'settings.notification_mode.both': 'Окно и уведомление',
'settings.sound.label': 'Звук уведомления', 'settings.sound.label': 'Звук уведомления',
'settings.sound.hint': 'Короткий сигнал при срабатывании', 'settings.sound.hint': 'Короткий сигнал при срабатывании',
'settings.voice.label': 'Голосовая подсказка',
'settings.voice.hint':
'Диктор произносит название упражнения и количество — полезно когда фокус на коде.',
'settings.meeting_pause.label': 'Пауза на встречах',
'settings.meeting_pause.hint':
'Не дёргать, если запущен Zoom / Teams / Discord / Webex / Slack-huddle.',
'settings.snooze.label': '«Отложить» на', 'settings.snooze.label': '«Отложить» на',
'settings.snooze.hint': 'Сколько минут добавлять при отложении', 'settings.snooze.hint': 'Сколько минут добавлять при отложении',
'settings.snooze.1': '1 минута', 'settings.snooze.1': '1 минута',
@@ -185,21 +231,94 @@ export const ru: Dict = {
'updater.checking': 'Проверяем обновления…', 'updater.checking': 'Проверяем обновления…',
'updater.up_to_date': 'Последняя версия', 'updater.up_to_date': 'Последняя версия',
'updater.up_to_date.subtitle': 'Текущая: v{v}', 'updater.up_to_date.subtitle': 'Текущая: v{v}',
'updater.up_to_date.subtitle_checked': 'Текущая: v{v} · проверено {when}',
'updater.last_checked': 'проверено {when}',
'updater.checked.just_now': 'только что',
'updater.checked.minutes_ago': '{n} мин назад',
'updater.checked.hours_ago': '{n} ч назад',
'updater.available.title': 'Доступна v{v}', 'updater.available.title': 'Доступна v{v}',
'updater.downloading.title': 'Загружаем обновление', 'updater.downloading.title': 'Загружаем обновление',
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с', 'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
'updater.downloading.hint': 'Можно закрыть это окно — скачивание продолжится в фоне.',
'updater.downloaded.title': 'Готово · v{v}', 'updater.downloaded.title': 'Готово · v{v}',
'updater.downloaded.subtitle': 'Перезапусти для применения', 'updater.downloaded.subtitle': 'Нажми «Рестарт» — приложение моментально откроется в новой версии.',
'updater.error.title': 'Ошибка проверки', 'updater.error.title': 'Ошибка проверки',
'updater.idle.title': 'Проверить обновления', 'updater.idle.title': 'Проверить обновления',
'updater.idle.subtitle': 'Авто-проверка раз в час', 'updater.idle.subtitle': 'Авто-проверка раз в час',
// Achievements
'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': 'Пятьсот',
'achievement.reps_1000.title': 'Тысяча',
'achievement.reps_5000.title': 'Пять тысяч',
'achievement.reps_10000.title': 'Десять тысяч',
'achievement.streak.desc': '{target} дней подряд',
'achievement.streak_3.title': 'Три дня',
'achievement.streak_7.title': 'Неделя',
'achievement.streak_14.title': 'Две недели',
'achievement.streak_30.title': 'Месяц',
'achievement.streak_100.title': 'Сто дней',
'achievement.first_day.title': 'Первый шаг',
'achievement.first_day.desc': 'Закрой первое напоминание',
'achievement.today_quad.title': 'Ударный день',
'achievement.today_quad.desc': '40+ повторений за день',
// Categories
'category.exercise': 'Упражнение',
'category.hydration': 'Гидратация',
'category.eyes': 'Отдых глазам',
'category.posture': 'Осанка',
'category.exercise.cta': 'Время тренировки',
'category.hydration.cta': 'Время попить',
'category.eyes.cta': 'Дай глазам отдохнуть',
'category.posture.cta': 'Проверь осанку',
'editor.field.category': 'Категория',
'editor.field.daily_goal': 'Дневная цель',
'editor.field.daily_goal.placeholder': 'без ограничения',
'editor.field.daily_goal.clear': 'Снять',
'editor.field.daily_goal.hint':
'Когда наберёшь столько повторений за день, напоминания этого упражнения умолкнут до завтра.',
'editor.field.adaptive.label': 'Адаптивное расписание',
'editor.field.adaptive.hint':
'Шедулер изучает, в какие часы ты чаще делаешь упражнение, и сдвигает напоминания на удобное тебе время. Заработает после 10 событий в истории.',
// Reminder window // Reminder window
'reminder.kicker': 'Время тренировки', 'reminder.kicker': 'Время тренировки',
'reminder.subkicker': 'Двигайся', 'reminder.subkicker': 'Двигайся',
'reminder.reps': 'раз', 'reminder.reps': 'раз',
'reminder.next_in': 'Следующее через {interval}', 'reminder.next_in': 'Следующее через {interval}',
'reminder.partial': 'Засчитаем {actual} из {planned}', 'reminder.partial': 'Засчитаем {actual} из {planned}',
'reminder.aria.decrement': 'Уменьшить количество повторов',
'reminder.aria.increment': 'Увеличить количество повторов',
// Weekday short labels (Mon..Sun). Used by Settings days-of-week picker,
// HistoryHeatmap row axis, and Dashboard date headers. Index 0 = Sunday
// to match Date.getDay()'s convention so callers can use the value
// directly without re-mapping.
'weekday.short.0': 'Вс',
'weekday.short.1': 'Пн',
'weekday.short.2': 'Вт',
'weekday.short.3': 'Ср',
'weekday.short.4': 'Чт',
'weekday.short.5': 'Пт',
'weekday.short.6': 'Сб',
// History heatmap
'heatmap.title': 'Активность за 12 недель',
'heatmap.legend.less': 'Меньше',
'heatmap.legend.more': 'Больше',
'heatmap.tooltip.reps_one': '{n} повтор',
'heatmap.tooltip.reps_few': '{n} повтора',
'heatmap.tooltip.reps_many': '{n} повторов',
// Sidebar
'sidebar.aria.nav': 'Главная навигация',
'exercise.aria.toggle': 'Переключить упражнение «{name}»',
'reminder.btn.done': 'Готово', 'reminder.btn.done': 'Готово',
'match.title.won': 'Победа', 'match.title.won': 'Победа',
'match.title.lost': 'Поражение', 'match.title.lost': 'Поражение',
@@ -235,6 +354,8 @@ export const en: Dict = {
'sidebar.status_tracking': 'Activity tracking is on', 'sidebar.status_tracking': 'Activity tracking is on',
'titlebar.menu_aria': 'Menu', 'titlebar.menu_aria': 'Menu',
'titlebar.minimize_aria': 'Minimize', 'titlebar.minimize_aria': 'Minimize',
'titlebar.maximize_aria': 'Maximize',
'titlebar.restore_aria': 'Restore size',
'titlebar.tray_aria': 'To tray', 'titlebar.tray_aria': 'To tray',
'titlebar.close_aria': 'Close', 'titlebar.close_aria': 'Close',
'titlebar.app_title': 'Exercise Reminder', 'titlebar.app_title': 'Exercise Reminder',
@@ -243,6 +364,15 @@ export const en: Dict = {
'btn.add': 'Add', 'btn.add': 'Add',
'btn.new': 'New', 'btn.new': 'New',
'btn.cancel': 'Cancel', 'btn.cancel': 'Cancel',
'btn.ok': 'OK',
'btn.delete.confirm': 'Delete',
'exercise.delete.title': 'Delete exercise?',
'exercise.delete.body':
'Exercise "{name}" will be removed. History stays but will only show the name.',
'exercise.goal_reached.kicker': 'Goal hit',
'exercise.goal_reached.value': '{done}/{goal}',
'exercise.adaptive.badge': 'adaptive mode',
'settings.data.import.modal.title': 'Restore from file?',
'btn.save': 'Save', 'btn.save': 'Save',
'btn.done': 'Done', 'btn.done': 'Done',
'btn.start': 'Start', 'btn.start': 'Start',
@@ -277,9 +407,14 @@ export const en: Dict = {
'dashboard.stat.tracking': 'Match tracking', 'dashboard.stat.tracking': 'Match tracking',
'dashboard.stat.tracking.on': 'On', 'dashboard.stat.tracking.on': 'On',
'dashboard.stat.tracking.off': 'Off', 'dashboard.stat.tracking.off': 'Off',
'dashboard.stat.tracking.pending': 'Setup',
'dashboard.stat.tracking.subtitle_on': 'real-time', 'dashboard.stat.tracking.subtitle_on': 'real-time',
'dashboard.stat.tracking.subtitle_off': 'disabled', 'dashboard.stat.tracking.subtitle_off': 'disabled',
'dashboard.stat.tracking.subtitle_pending': 'close & reopen Steam',
'dashboard.paused.title': 'Reminders paused', 'dashboard.paused.title': 'Reminders paused',
'dashboard.meeting.title': "You're in a meeting — won't interrupt",
'dashboard.meeting.hint':
'Zoom / Teams / Discord / Webex / Slack-huddle is running. Reminders resume when you close it.',
'dashboard.paused.hint': 'Resume to continue countdown', 'dashboard.paused.hint': 'Resume to continue countdown',
'dashboard.empty.title': 'Program is empty', 'dashboard.empty.title': 'Program is empty',
'dashboard.empty.hint': 'Add your first exercise to start', 'dashboard.empty.hint': 'Add your first exercise to start',
@@ -355,6 +490,30 @@ export const en: Dict = {
'settings.section.appearance': 'Appearance', 'settings.section.appearance': 'Appearance',
'settings.section.language': 'Language', 'settings.section.language': 'Language',
'settings.section.updates': 'Updates', 'settings.section.updates': 'Updates',
'settings.section.data': 'Data',
'settings.data.export.label': 'Export everything',
'settings.data.export.hint':
'Save a backup of exercises, history, challenges and settings to a JSON file.',
'settings.data.export.btn': 'Save',
'settings.data.export.ok': 'Saved to {path}',
'settings.data.export.err': 'Could not save',
'settings.data.import.label': 'Restore from file',
'settings.data.import.hint':
'Load a previously saved backup. Current data will be overwritten.',
'settings.data.import.btn': 'Restore',
'settings.data.import.confirm':
'All current exercises, history and settings will be replaced with the file contents. Continue?',
'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',
'whatsnew.title': "What's new",
'whatsnew.btn.close': 'Got it',
'whatsnew.empty': 'No notes available for this version yet.',
'settings.notification_mode.label': 'Notification mode', 'settings.notification_mode.label': 'Notification mode',
'settings.notification_mode.hint': 'How a reminder appears', 'settings.notification_mode.hint': 'How a reminder appears',
'settings.notification_mode.modal': 'Window on top', 'settings.notification_mode.modal': 'Window on top',
@@ -362,6 +521,12 @@ export const en: Dict = {
'settings.notification_mode.both': 'Window and notification', 'settings.notification_mode.both': 'Window and notification',
'settings.sound.label': 'Notification sound', 'settings.sound.label': 'Notification sound',
'settings.sound.hint': 'Short beep on trigger', 'settings.sound.hint': 'Short beep on trigger',
'settings.voice.label': 'Voice prompt',
'settings.voice.hint':
'Speaks the exercise name and count — useful when your eyes are on the code.',
'settings.meeting_pause.label': 'Pause during meetings',
'settings.meeting_pause.hint':
'Skip reminders when Zoom / Teams / Discord / Webex / Slack-huddle is running.',
'settings.snooze.label': '“Snooze” for', 'settings.snooze.label': '“Snooze” for',
'settings.snooze.hint': 'How many minutes to postpone', 'settings.snooze.hint': 'How many minutes to postpone',
'settings.snooze.1': '1 minute', 'settings.snooze.1': '1 minute',
@@ -398,21 +563,90 @@ export const en: Dict = {
'updater.checking': 'Checking for updates…', 'updater.checking': 'Checking for updates…',
'updater.up_to_date': 'Up to date', 'updater.up_to_date': 'Up to date',
'updater.up_to_date.subtitle': 'Current: v{v}', 'updater.up_to_date.subtitle': 'Current: v{v}',
'updater.up_to_date.subtitle_checked': 'Current: v{v} · checked {when}',
'updater.last_checked': 'checked {when}',
'updater.checked.just_now': 'just now',
'updater.checked.minutes_ago': '{n}m ago',
'updater.checked.hours_ago': '{n}h ago',
'updater.available.title': 'v{v} available', 'updater.available.title': 'v{v} available',
'updater.downloading.title': 'Downloading update', 'updater.downloading.title': 'Downloading update',
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s', 'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s',
'updater.downloading.hint': 'You can close this window — download continues in the background.',
'updater.downloaded.title': 'Ready · v{v}', 'updater.downloaded.title': 'Ready · v{v}',
'updater.downloaded.subtitle': 'Restart to apply', 'updater.downloaded.subtitle': 'Click Restart — the app will reopen instantly in the new version.',
'updater.error.title': 'Check failed', 'updater.error.title': 'Check failed',
'updater.idle.title': 'Check for updates', 'updater.idle.title': 'Check for updates',
'updater.idle.subtitle': 'Auto-check every hour', 'updater.idle.subtitle': 'Auto-check every hour',
// Achievements
'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',
'achievement.reps_1000.title': 'Thousand',
'achievement.reps_5000.title': 'Five thousand',
'achievement.reps_10000.title': 'Ten thousand',
'achievement.streak.desc': '{target} days in a row',
'achievement.streak_3.title': 'Three days',
'achievement.streak_7.title': 'Week',
'achievement.streak_14.title': 'Two weeks',
'achievement.streak_30.title': 'Month',
'achievement.streak_100.title': 'Hundred days',
'achievement.first_day.title': 'First step',
'achievement.first_day.desc': 'Close your first reminder',
'achievement.today_quad.title': 'Strong day',
'achievement.today_quad.desc': '40+ reps in one day',
// Categories
'category.exercise': 'Exercise',
'category.hydration': 'Hydration',
'category.eyes': 'Eye rest',
'category.posture': 'Posture',
'category.exercise.cta': 'Workout time',
'category.hydration.cta': 'Time to drink',
'category.eyes.cta': 'Rest your eyes',
'category.posture.cta': 'Check your posture',
'editor.field.category': 'Category',
'editor.field.daily_goal': 'Daily goal',
'editor.field.daily_goal.placeholder': 'no limit',
'editor.field.daily_goal.clear': 'Clear',
'editor.field.daily_goal.hint':
'Once you hit this many reps in a day, this reminder goes quiet until tomorrow.',
'editor.field.adaptive.label': 'Adaptive scheduling',
'editor.field.adaptive.hint':
'Scheduler learns which hours you reliably do this exercise and shifts reminders into your good windows. Kicks in after 10 history events.',
// Reminder window // Reminder window
'reminder.kicker': 'Workout time', 'reminder.kicker': 'Workout time',
'reminder.subkicker': 'Move', 'reminder.subkicker': 'Move',
'reminder.reps': 'reps', 'reminder.reps': 'reps',
'reminder.next_in': 'Next in {interval}', 'reminder.next_in': 'Next in {interval}',
'reminder.partial': "We'll log {actual} of {planned}", 'reminder.partial': "We'll log {actual} of {planned}",
'reminder.aria.decrement': 'Decrease rep count',
'reminder.aria.increment': 'Increase rep count',
// Weekday short labels (Mon..Sun). Index 0 = Sunday.
'weekday.short.0': 'Sun',
'weekday.short.1': 'Mon',
'weekday.short.2': 'Tue',
'weekday.short.3': 'Wed',
'weekday.short.4': 'Thu',
'weekday.short.5': 'Fri',
'weekday.short.6': 'Sat',
// History heatmap
'heatmap.title': 'Activity, last 12 weeks',
'heatmap.legend.less': 'Less',
'heatmap.legend.more': 'More',
'heatmap.tooltip.reps_one': '{n} rep',
'heatmap.tooltip.reps_many': '{n} reps',
// Sidebar
'sidebar.aria.nav': 'Main navigation',
'exercise.aria.toggle': 'Toggle exercise "{name}"',
'reminder.btn.done': 'Done', 'reminder.btn.done': 'Done',
'match.title.won': 'Victory', 'match.title.won': 'Victory',
'match.title.lost': 'Defeat', 'match.title.lost': 'Defeat',

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { translate, translateN } from './index' import { translate, translateN } from './index'
import { ru, en } from './dict'
describe('translate', () => { describe('translate', () => {
it('returns the matching string by key', () => { it('returns the matching string by key', () => {
@@ -30,6 +31,50 @@ describe('translate', () => {
// @ts-expect-error testing fallback // @ts-expect-error testing fallback
expect(translate('fr', 'btn.save')).toBe('Сохранить') expect(translate('fr', 'btn.save')).toBe('Сохранить')
}) })
// Регрессия: до v0.5.2 интерполяция шла через regex, и если
// var-значение содержало regex-метасимволы ($1, .*, и т.д.), они
// интерпретировались как backreferences. Сейчас split/join.
it('substitutes regex metacharacters literally (no regex injection)', () => {
expect(
translate('ru', 'btn.snooze_min', { n: '$1.*' as unknown as number })
).toBe('Отложить $1.* мин')
expect(
translate('en', 'btn.snooze_min', {
n: '$$$&\\1' as unknown as number
})
).toBe('Snooze $$$&\\1m')
})
it('leaves unsubstituted placeholders intact', () => {
// {n} остаётся как есть, если var не передан — это сигнал «забыл vars».
expect(translate('ru', 'btn.snooze_min')).toContain('{n}')
})
})
describe('dictionary parity', () => {
// EN не имеет CLDR-категории `few` — только `one`/`many`. Поэтому RU-ключи
// вида `*_few` легитимно отсутствуют в EN, исключаем их из парити-чека.
const isRuFewOnly = (k: string): boolean => k.endsWith('_few')
it('every key in ru (except *_few) exists in en', () => {
const missing = Object.keys(ru).filter(
(k) => !isRuFewOnly(k) && !(k in en)
)
expect(missing, `missing in en: ${missing.join(', ')}`).toEqual([])
})
it('every key in en exists in ru', () => {
const missing = Object.keys(en).filter((k) => !(k in ru))
expect(missing, `missing in ru: ${missing.join(', ')}`).toEqual([])
})
it('weekday.short.0..6 exist in both languages', () => {
for (const i of [0, 1, 2, 3, 4, 5, 6]) {
expect(ru[`weekday.short.${i}`]).toBeTruthy()
expect(en[`weekday.short.${i}`]).toBeTruthy()
}
})
}) })
describe('translateN (plural)', () => { describe('translateN (plural)', () => {

View File

@@ -9,21 +9,30 @@ export function getDict(lang: Language): Dict {
} }
export type TVars = Record<string, string | number> export type TVars = Record<string, string | number>
export type TFn = (key: string, vars?: TVars) => string
/** /**
* Look up a key in the dictionary, substitute `{var}` placeholders. * Look up a key in the dictionary, substitute `{var}` placeholders.
* Returns the key itself if not found — surfaces missing translations. * Returns the key itself if not found — surfaces missing translations.
*
* Substitution is done by string split/join rather than a regex so that
* variable values containing regex metacharacters (e.g. a user-supplied
* exercise name with `$1` or `.*`) are interpolated literally.
*/ */
export function translate( export function translate(lang: Language, key: string, vars?: TVars): string {
lang: Language,
key: string,
vars?: TVars
): string {
const dict = getDict(lang) const dict = getDict(lang)
let s = dict[key] ?? key let s = dict[key]
if (s === undefined) {
// Surface missing translations in dev; in prod render the key so the user
// sees something deterministic instead of a blank.
if (import.meta.env.DEV) {
console.warn(`[i18n] missing key "${key}" for lang "${lang}"`)
}
s = key
}
if (vars) { if (vars) {
for (const k of Object.keys(vars)) { for (const k of Object.keys(vars)) {
s = s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(vars[k])) s = s.split(`{${k}}`).join(String(vars[k]))
} }
} }
return s return s
@@ -36,8 +45,10 @@ export function translate(
* many → 0, 5-20, 25-30… * many → 0, 5-20, 25-30…
*/ */
function pluralRu(n: number): 'one' | 'few' | 'many' { function pluralRu(n: number): 'one' | 'few' | 'many' {
const mod10 = n % 10 // Always pluralize on the absolute value — a "-1" count is the same form as "1".
const mod100 = n % 100 const abs = Math.abs(Math.trunc(n))
const mod10 = abs % 10
const mod100 = abs % 100
if (mod10 === 1 && mod100 !== 11) return 'one' if (mod10 === 1 && mod100 !== 11) return 'one'
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 'few' if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 'few'
return 'many' return 'many'
@@ -54,12 +65,7 @@ export function translateN(
n: number, n: number,
vars?: TVars vars?: TVars
): string { ): string {
const form = const form = lang === 'ru' ? pluralRu(n) : n === 1 ? 'one' : 'many'
lang === 'ru'
? pluralRu(n)
: n === 1
? 'one'
: 'many'
return translate(lang, `${keyBase}_${form}`, { n, ...vars }) return translate(lang, `${keyBase}_${form}`, { n, ...vars })
} }

View File

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

View File

@@ -0,0 +1,155 @@
/**
* Достижения = derived data из истории. Не persisting'ятся: при каждом
* прокладывании Dashboard'а пересчитываются из истории.
*
* Определение достижения: id, человеческий label/description, иконка
* (Lucide name), функция-проверка `progress(history, exercises) →
* { current, target }` где `current >= target` означает «получено».
*
* Этим UI получает не только список «полученных», но и прогресс по «почти-
* полученным», что важно мотивационно: «осталось 12 повторов до значка
* Сотня».
*/
import type { Exercise, HistoryEntry } from '@shared/types'
import { currentStreak, dailyReps, dayKey } from './history'
export type AchievementDef = {
id: string
/** Lucide icon name (whitelisted ICON_CHOICES не обязательно — иконки
* достижений отдельный набор). */
icon: string
tone: 'accent' | 'warning' | 'success' | 'info'
/** i18n-ключ для названия. */
titleKey: string
/** i18n-ключ описания. Принимает {target}. */
descKey: string
}
export type AchievementProgress = {
def: AchievementDef
current: number
target: number
/** Получено = current >= target. */
unlocked: boolean
}
/** Сумма всех done-повторений за всё время (учитывая actualReps). */
function totalDoneReps(
history: HistoryEntry[],
exercises: Exercise[]
): number {
const byId = new Map(exercises.map((e) => [e.id, e]))
let sum = 0
for (const e of history) {
if (e.action !== 'done') continue
sum += e.actualReps ?? e.reps ?? byId.get(e.exerciseId)?.reps ?? 0
}
return sum
}
/** Сколько уникальных done-дней за всё время. */
function totalDoneDays(history: HistoryEntry[]): number {
const days = new Set<string>()
for (const e of history) {
if (e.action === 'done') days.add(dayKey(e.ts))
}
return days.size
}
/** Самый длинный завершённый streak (для historic-достижений). */
function longestStreak(history: HistoryEntry[]): number {
const days = new Set<string>()
for (const e of history) {
if (e.action === 'done') days.add(dayKey(e.ts))
}
const sorted = Array.from(days).sort()
let max = 0
let cur = 0
let prev = ''
for (const d of sorted) {
if (prev) {
const prevDate = new Date(prev + 'T00:00:00')
const curDate = new Date(d + 'T00:00:00')
const diffDays = Math.round(
(curDate.getTime() - prevDate.getTime()) / (24 * 60 * 60 * 1000)
)
cur = diffDays === 1 ? cur + 1 : 1
} else {
cur = 1
}
if (cur > max) max = cur
prev = d
}
return max
}
const REPS_MILESTONES = [100, 500, 1000, 5000, 10000] as const
const STREAK_MILESTONES = [3, 7, 14, 30, 100] as const
const DEFINITIONS: AchievementDef[] = [
...REPS_MILESTONES.map<AchievementDef>((n) => ({
id: `reps_${n}`,
icon: 'Activity',
tone: 'accent' as const,
titleKey: `achievement.reps_${n}.title`,
descKey: 'achievement.reps.desc'
})),
...STREAK_MILESTONES.map<AchievementDef>((n) => ({
id: `streak_${n}`,
icon: 'Flame',
tone: 'warning' as const,
titleKey: `achievement.streak_${n}.title`,
descKey: 'achievement.streak.desc'
})),
{
id: 'first_day',
icon: 'Sparkles',
tone: 'success',
titleKey: 'achievement.first_day.title',
descKey: 'achievement.first_day.desc'
},
{
id: 'today_quad',
icon: 'TrendingUp',
tone: 'info',
titleKey: 'achievement.today_quad.title',
descKey: 'achievement.today_quad.desc'
}
]
export function computeAchievements(
history: HistoryEntry[],
exercises: Exercise[]
): AchievementProgress[] {
const total = totalDoneReps(history, exercises)
const days = totalDoneDays(history)
const longest = longestStreak(history)
const currentStreakLen = currentStreak(history)
const today = dayKey(Date.now())
const todayCount = dailyReps(history, exercises, today)
return DEFINITIONS.map<AchievementProgress>((def) => {
if (def.id.startsWith('reps_')) {
const target = Number(def.id.split('_')[1])
return { def, current: total, target, unlocked: total >= target }
}
if (def.id.startsWith('streak_')) {
const target = Number(def.id.split('_')[1])
// Учитываем максимальный исторический и текущий — берём больший.
const cur = Math.max(longest, currentStreakLen)
return { def, current: cur, target, unlocked: cur >= target }
}
if (def.id === 'first_day') {
return { def, current: days >= 1 ? 1 : 0, target: 1, unlocked: days >= 1 }
}
if (def.id === 'today_quad') {
return {
def,
current: todayCount,
target: 40,
unlocked: todayCount >= 40
}
}
return { def, current: 0, target: 1, unlocked: false }
})
}

View File

@@ -33,6 +33,29 @@ describe('formatCountdown', () => {
expect(formatCountdown(999)).toBe('0с') expect(formatCountdown(999)).toBe('0с')
expect(formatCountdown(500)).toBe('0с') expect(formatCountdown(500)).toBe('0с')
}) })
// Guard added in v0.5.2 — electron-updater и scheduler могут передать
// NaN/Infinity на ранних событиях. Должны вернуть «сейчас», не «NaNс».
it('returns "сейчас" for NaN and Infinity (defensive guard)', () => {
expect(formatCountdown(NaN)).toBe('сейчас')
expect(formatCountdown(Infinity)).toBe('сейчас')
expect(formatCountdown(-Infinity)).toBe('сейчас')
})
describe('english locale', () => {
it('renders sub-minute with "s"', () => {
expect(formatCountdown(45_000, 'en')).toBe('45s')
})
it('renders minutes+seconds with "m"/"s"', () => {
expect(formatCountdown(65_000, 'en')).toBe('1m 05s')
})
it('renders hours+minutes with "h"/"m"', () => {
expect(formatCountdown(3_660_000, 'en')).toBe('1h 01m')
})
it('returns "now" for zero', () => {
expect(formatCountdown(0, 'en')).toBe('now')
})
})
}) })
describe('formatInterval', () => { describe('formatInterval', () => {
@@ -53,4 +76,10 @@ describe('formatInterval', () => {
expect(formatInterval(90)).toBe('1 ч 30 мин') expect(formatInterval(90)).toBe('1 ч 30 мин')
expect(formatInterval(125)).toBe('2 ч 5 мин') expect(formatInterval(125)).toBe('2 ч 5 мин')
}) })
it('english locale', () => {
expect(formatInterval(30, 'en')).toBe('30 min')
expect(formatInterval(60, 'en')).toBe('1 h')
expect(formatInterval(90, 'en')).toBe('1 h 30 min')
})
}) })

View File

@@ -7,7 +7,7 @@ const SUFFIX = {
export function formatCountdown(ms: number, lang: Language = 'ru'): string { export function formatCountdown(ms: number, lang: Language = 'ru'): string {
const s = SUFFIX[lang] ?? SUFFIX.ru const s = SUFFIX[lang] ?? SUFFIX.ru
if (ms <= 0) return s.now if (!Number.isFinite(ms) || ms <= 0) return s.now
const totalSec = Math.floor(ms / 1000) const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600) const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60) const m = Math.floor((totalSec % 3600) / 60)
@@ -17,10 +17,7 @@ export function formatCountdown(ms: number, lang: Language = 'ru'): string {
return `${sec}${s.s}` return `${sec}${s.s}`
} }
export function formatInterval( export function formatInterval(minutes: number, lang: Language = 'ru'): string {
minutes: number,
lang: Language = 'ru'
): string {
const s = SUFFIX[lang] ?? SUFFIX.ru const s = SUFFIX[lang] ?? SUFFIX.ru
if (minutes < 60) return `${minutes} ${s.minLong}` if (minutes < 60) return `${minutes} ${s.minLong}`
const h = Math.floor(minutes / 60) const h = Math.floor(minutes / 60)

View File

@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { Exercise, HistoryEntry } from '@shared/types' import type { Exercise, HistoryEntry } from '@shared/types'
import { currentStreak, dailyReps, dayKey, dailyRepsRange } from './history' import {
currentStreak,
dailyReps,
dayKey,
dailyRepsRange,
plannedRepsToday,
repsDoneTodayForExercise
} from './history'
const MS_DAY = 24 * 60 * 60 * 1000 const MS_DAY = 24 * 60 * 60 * 1000
@@ -94,19 +101,12 @@ describe('currentStreak', () => {
}) })
it('ignores skip and snooze', () => { it('ignores skip and snooze', () => {
const hist = [ const hist = [entry('a', day(0), 'skip'), entry('a', day(1), 'snooze')]
entry('a', day(0), 'skip'),
entry('a', day(1), 'snooze')
]
expect(currentStreak(hist)).toBe(0) expect(currentStreak(hist)).toBe(0)
}) })
it('multiple entries same day count once', () => { it('multiple entries same day count once', () => {
const hist = [ const hist = [entry('a', day(0)), entry('b', day(0)), entry('a', day(1))]
entry('a', day(0)),
entry('b', day(0)),
entry('a', day(1))
]
expect(currentStreak(hist)).toBe(2) expect(currentStreak(hist)).toBe(2)
}) })
}) })
@@ -124,4 +124,179 @@ describe('dailyRepsRange', () => {
expect(range.at(-1)?.reps).toBe(10) // today expect(range.at(-1)?.reps).toBe(10) // today
expect(range.at(-2)?.reps).toBe(3) // yesterday, partial expect(range.at(-2)?.reps).toBe(3) // yesterday, partial
}) })
// DST regression: до v0.5.2 dailyRepsRange использовал `ts - i*MS_DAY`.
// На границе DST (например в EU last Sunday October — 25 час) арифметика
// ms-vs-календарь расходилась, и dayKey() выдавал дубликат/пропуск дня.
// Сейчас shiftDays() через setDate(). Простой инвариант: количество
// уникальных day-keys всегда == days, и все keys строго возрастают.
it('produces unique day keys without gaps (DST-safe)', () => {
const range = dailyRepsRange([], [], 90)
const keys = range.map((r) => r.key)
expect(new Set(keys).size).toBe(90)
for (let i = 1; i < keys.length; i++) {
expect(keys[i] > keys[i - 1]).toBe(true)
}
})
it('last entry is today', () => {
const range = dailyRepsRange([], [], 7)
expect(range.at(-1)?.key).toBe(dayKey(Date.now()))
})
})
describe('plannedRepsToday', () => {
it('returns 0 when no exercises enabled', () => {
const exs = [{ ...ex('a', 10), enabled: false }]
expect(plannedRepsToday(exs)).toBe(0)
})
it('returns 0 for empty list', () => {
expect(plannedRepsToday([])).toBe(0)
})
it('multiplies reps by approximate fires per day', () => {
// 60-min interval × 24 = 24 fires/day × 10 reps = 240
const exs = [{ ...ex('a', 10), intervalMinutes: 60 }]
expect(plannedRepsToday(exs)).toBe(240)
})
it('sums across multiple enabled exercises', () => {
const exs = [
{ ...ex('a', 10), intervalMinutes: 60 }, // 24 × 10 = 240
{ ...ex('b', 5), intervalMinutes: 30 } // 48 × 5 = 240
]
expect(plannedRepsToday(exs)).toBe(480)
})
it('floor of (1440/interval), minimum 1 fire/day for huge intervals', () => {
// 1440-min interval = 1 fire/day; 2000-min interval should still be ≥ 1.
const exs = [{ ...ex('a', 7), intervalMinutes: 2000 }]
expect(plannedRepsToday(exs)).toBe(7)
})
})
describe('currentStreak edge cases', () => {
const today = Date.now()
it('ignores future-dated entries (clock skew, partial restore)', () => {
const tomorrow = today + 24 * 60 * 60 * 1000
// future entry shouldn't anchor the streak.
expect(currentStreak([entry('a', tomorrow)])).toBe(0)
})
it('handles entries spread across the same day with mixed actions', () => {
const e = (
action: 'done' | 'skip' | 'snooze',
ts: number
): HistoryEntry => entry('a', ts, action)
const hist = [
e('skip', today),
e('done', today), // done is enough — streak counts the day
e('snooze', today)
]
expect(currentStreak(hist)).toBe(1)
})
})
describe('repsDoneTodayForExercise', () => {
const today = Date.now()
const exercise = ex('a', 10)
const other = ex('b', 5)
it('returns 0 if no entries', () => {
expect(repsDoneTodayForExercise([], exercise)).toBe(0)
})
it('counts only entries for this exercise today', () => {
const hist = [
entry('a', today),
entry('a', today),
entry('b', today), // other exercise — игнорируем
entry('a', today - 2 * 24 * 60 * 60 * 1000) // позавчера — игнорируем
]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(20)
expect(repsDoneTodayForExercise(hist, other)).toBe(5)
})
it('uses actualReps when set', () => {
const hist = [entry('a', today, 'done', 7), entry('a', today)]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(7 + 10)
})
it('ignores skip / snooze entries', () => {
const hist = [
entry('a', today, 'skip'),
entry('a', today, 'snooze'),
entry('a', today)
]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(10)
})
it('prefers entry.reps snapshot over exercise.reps (historical accuracy)', () => {
// Контракт: entry.reps это «сколько было запланировано на момент
// записи». Если пользователь раньше делал 15 раз приседаний, потом
// изменил планку на 10 — history должна показывать 15 для старых
// entries, не 10. Это правильнее для аналитики «что я тогда делал».
const histWithSnapshot: HistoryEntry[] = [
{ exerciseId: 'a', ts: today, action: 'done', reps: 15 }
]
expect(repsDoneTodayForExercise(histWithSnapshot, exercise)).toBe(15)
})
it('falls back to exercise.reps when entry has no snapshot', () => {
// Старые entries (до Sprint #1 / v0.5.7) не имеют entry.reps.
// Должны fall'back'нуться на текущий exercise.reps.
const histOldEntry: HistoryEntry[] = [
{ exerciseId: 'a', ts: today, action: 'done' }
]
expect(repsDoneTodayForExercise(histOldEntry, exercise)).toBe(10)
})
it('survives match challenges (exerciseId=challenge:<id>)', () => {
// Match-челлендж не привязан к exercise — repsDoneTodayForExercise
// его игнорирует (это не reps для этого упражнения).
const hist: HistoryEntry[] = [
{
exerciseId: 'challenge:abc',
ts: today,
action: 'done',
actualReps: 30,
reps: 30,
source: 'match'
}
]
expect(repsDoneTodayForExercise(hist, exercise)).toBe(0)
})
})
describe('dailyReps with new entry.reps snapshot', () => {
const today = Date.now()
const exs = [ex('a', 10)]
it('counts match-challenge entries via entry.reps snapshot', () => {
// У match-челленджа exerciseId='challenge:<id>', byId.get вернёт
// undefined. entry.reps snapshot — единственный источник.
const hist: HistoryEntry[] = [
{
exerciseId: 'challenge:abc',
ts: today,
action: 'done',
actualReps: 30,
reps: 30,
source: 'match'
},
entry('a', today) // обычная entry — 10 reps через byId
]
expect(dailyReps(hist, exs, dayKey(today))).toBe(40)
})
it('survives deleted exercise via entry.reps snapshot', () => {
// Упражнение 'gone' удалено, но entry.reps=8 был записан до удаления.
const hist: HistoryEntry[] = [
{ exerciseId: 'gone', ts: today, action: 'done', reps: 8 }
]
// byId.get('gone') = undefined → fallback на entry.reps=8.
expect(dailyReps(hist, exs, dayKey(today))).toBe(8)
})
}) })

View File

@@ -1,7 +1,5 @@
import type { Exercise, HistoryEntry } from '@shared/types' import type { Exercise, HistoryEntry } from '@shared/types'
const MS_DAY = 24 * 60 * 60 * 1000
/** YYYY-MM-DD in local time. */ /** YYYY-MM-DD in local time. */
export function dayKey(ts: number): string { export function dayKey(ts: number): string {
const d = new Date(ts) const d = new Date(ts)
@@ -16,10 +14,51 @@ export function todayKey(): string {
return dayKey(Date.now()) return dayKey(Date.now())
} }
/**
* Return a new Date offset by `dayDelta` calendar days from `base`, with the
* time-of-day preserved. Uses `setDate` (calendar arithmetic) rather than
* subtracting `n * 24h` of milliseconds — across DST transitions ms arithmetic
* drifts by ±1h and `dayKey` can emit the wrong day.
*/
function shiftDays(base: Date, dayDelta: number): Date {
const d = new Date(base.getTime())
d.setDate(d.getDate() + dayDelta)
return d
}
/** /**
* Reps logged on a given local day. Uses `actualReps` if present, otherwise * Reps logged on a given local day. Uses `actualReps` if present, otherwise
* looks up exercise's planned `reps`. * looks up exercise's planned `reps`.
*/ */
/**
* Сколько reps пользователь сделал в заданный day-key. Источники в порядке
* приоритета:
* 1. entry.actualReps — что фактически сделал (stepper в reminder'е)
* 2. entry.reps — snapshot planned-reps на момент записи (выживает после
* удаления упражнения и работает для match-челленджей у которых нет
* связанного Exercise)
* 3. byId.get(exerciseId).reps — fallback для старых entries без snapshot'а
*/
/**
* Сколько reps конкретное упражнение принесло за сегодня. Учитываем как
* обычные «по таймеру», так и match-челленджи (если их exerciseId совпадает,
* чего обычно нет; но fallback не помешает).
*/
export function repsDoneTodayForExercise(
entries: HistoryEntry[],
exercise: Exercise
): number {
const today = todayKey()
let sum = 0
for (const e of entries) {
if (e.action !== 'done') continue
if (e.exerciseId !== exercise.id) continue
if (dayKey(e.ts) !== today) continue
sum += e.actualReps ?? e.reps ?? exercise.reps
}
return sum
}
export function dailyReps( export function dailyReps(
entries: HistoryEntry[], entries: HistoryEntry[],
exercises: Exercise[], exercises: Exercise[],
@@ -30,7 +69,7 @@ export function dailyReps(
for (const e of entries) { for (const e of entries) {
if (e.action !== 'done') continue if (e.action !== 'done') continue
if (dayKey(e.ts) !== dayKeyStr) continue if (dayKey(e.ts) !== dayKeyStr) continue
sum += e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0 sum += e.actualReps ?? e.reps ?? byId.get(e.exerciseId)?.reps ?? 0
} }
return sum return sum
} }
@@ -46,28 +85,27 @@ export function dailyRepsRange(
): { key: string; date: Date; reps: number }[] { ): { key: string; date: Date; reps: number }[] {
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
const buckets = new Map<string, number>() const buckets = new Map<string, { date: Date; reps: number }>()
const byId = new Map(exercises.map((e) => [e.id, e])) const byId = new Map(exercises.map((e) => [e.id, e]))
// Seed all days with 0 so heatmap renders contiguous. // Seed all days with 0 so heatmap renders contiguous. Use calendar arithmetic
// (setDate) — DST transitions would shift epoch-based math by ±1h, causing
// dayKey() to emit duplicate or missing days at the boundary.
for (let i = days - 1; i >= 0; i--) { for (let i = days - 1; i >= 0; i--) {
const d = new Date(today.getTime() - i * MS_DAY) const d = shiftDays(today, -i)
buckets.set(dayKey(d.getTime()), 0) buckets.set(dayKey(d.getTime()), { date: d, reps: 0 })
} }
for (const e of entries) { for (const e of entries) {
if (e.action !== 'done') continue if (e.action !== 'done') continue
const k = dayKey(e.ts) const k = dayKey(e.ts)
if (!buckets.has(k)) continue const bucket = buckets.get(k)
const reps = e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0 if (!bucket) continue
buckets.set(k, (buckets.get(k) ?? 0) + reps) const reps = e.actualReps ?? e.reps ?? byId.get(e.exerciseId)?.reps ?? 0
bucket.reps += reps
} }
return Array.from(buckets, ([key, reps]) => ({ return Array.from(buckets, ([key, { date, reps }]) => ({ key, date, reps }))
key,
date: new Date(`${key}T00:00:00`),
reps
}))
} }
/** /**
@@ -84,21 +122,22 @@ export function currentStreak(entries: HistoryEntry[]): number {
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
const yesterday = shiftDays(today, -1)
const todayK = dayKey(today.getTime()) const todayK = dayKey(today.getTime())
const yesterdayK = dayKey(today.getTime() - MS_DAY) const yesterdayK = dayKey(yesterday.getTime())
// Start from today if active today, else yesterday (grace), else 0. // Start from today if active today, else yesterday (grace), else 0.
let cursor = doneDays.has(todayK) let cursor: Date | null = doneDays.has(todayK)
? today ? today
: doneDays.has(yesterdayK) : doneDays.has(yesterdayK)
? new Date(today.getTime() - MS_DAY) ? yesterday
: null : null
if (!cursor) return 0 if (!cursor) return 0
let streak = 0 let streak = 0
while (doneDays.has(dayKey(cursor.getTime()))) { while (doneDays.has(dayKey(cursor.getTime()))) {
streak++ streak++
cursor = new Date(cursor.getTime() - MS_DAY) cursor = shiftDays(cursor, -1)
} }
return streak return streak
} }

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { ICON_CHOICES } from './icon-choices'
import { SAMPLE_EXERCISES } from '@shared/types'
describe('ICON_CHOICES', () => {
// Если иконка SAMPLE_EXERCISES не входит в whitelist, при первом запуске
// приложения иконка молча заменится на fallback-Activity. Лучше ловить
// расхождение в CI.
it('contains every icon used by SAMPLE_EXERCISES', () => {
const allowed = new Set<string>(ICON_CHOICES)
for (const ex of SAMPLE_EXERCISES) {
expect(
allowed.has(ex.icon),
`icon "${ex.icon}" for sample "${ex.name}" is not in ICON_CHOICES`
).toBe(true)
}
})
it('has no duplicates', () => {
expect(new Set(ICON_CHOICES).size).toBe(ICON_CHOICES.length)
})
it('is non-empty', () => {
expect(ICON_CHOICES.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,27 @@
/**
* Whitelist of allowed Lucide-icon names. Wrapped in a separate .ts file
* (без JSX), чтобы его можно было импортировать из node-tests и из shared/
* без подтягивания JSX-зависимости icon.tsx.
*/
export const ICON_CHOICES = [
'Activity',
'Dumbbell',
'StretchHorizontal',
'PersonStanding',
'Heart',
'Footprints',
'Hand',
'Eye',
'Brain',
'Bike',
'Waves',
'Wind',
'Sun',
'Coffee',
'Apple',
'GlassWater',
'BookOpen',
'Sparkles'
] as const
export type IconName = (typeof ICON_CHOICES)[number]

View File

@@ -1,36 +1,68 @@
import * as Lucide from 'lucide-react' // Explicit-named imports — НЕ wildcard. Wildcard `* as Lucide` ломает
// tree-shaking: в bundle попадает вся библиотека (~500KB minified, 1500+
// иконок). Сейчас в bundle только 18 ICON_CHOICES.
import {
Activity,
Dumbbell,
StretchHorizontal,
PersonStanding,
Heart,
Footprints,
Hand,
Eye,
Brain,
Bike,
Waves,
Wind,
Sun,
Coffee,
Apple,
GlassWater,
BookOpen,
Sparkles
} from 'lucide-react'
import type { LucideProps } from 'lucide-react' import type { LucideProps } from 'lucide-react'
import { ICON_CHOICES, type IconName } from './icon-choices'
export const ICON_CHOICES = [ export { ICON_CHOICES, type IconName }
'Activity',
'Dumbbell',
'StretchHorizontal',
'PersonStanding',
'Heart',
'Footprints',
'Hand',
'Eye',
'Brain',
'Bike',
'Waves',
'Wind',
'Sun',
'Coffee',
'Apple',
'GlassWater',
'BookOpen',
'Sparkles'
] as const
export type IconName = (typeof ICON_CHOICES)[number] const ICON_MAP: Record<IconName, React.ComponentType<LucideProps>> = {
Activity,
Dumbbell,
StretchHorizontal,
PersonStanding,
Heart,
Footprints,
Hand,
Eye,
Brain,
Bike,
Waves,
Wind,
Sun,
Coffee,
Apple,
GlassWater,
BookOpen,
Sparkles
}
/**
* Render a Lucide icon by name. Restricted to the curated ICON_CHOICES set —
* an arbitrary string from a corrupted state file could otherwise resolve to
* unrelated Lucide exports (`default`, `createLucideIcon`, etc.) and crash
* the renderer.
*/
export function Icon({ export function Icon({
name, name,
...props ...props
}: { name: string } & LucideProps): JSX.Element { }: { name: string } & LucideProps): JSX.Element {
const Cmp = (Lucide as unknown as Record<string, React.ComponentType<LucideProps>>)[ const Cmp = ICON_MAP[name as IconName]
name if (!Cmp) {
] if (import.meta.env.DEV) {
if (!Cmp) return <Lucide.Activity {...props} /> console.warn(`[Icon] unknown icon name "${name}" — falling back`)
}
return <Activity {...props} />
}
return <Cmp {...props} /> return <Cmp {...props} />
} }

View File

@@ -0,0 +1,67 @@
/**
* Тонкая обёртка над Web Speech API для голосовых подсказок упражнений.
*
* Используется в ReminderApp при `settings.voicePromptsEnabled`. Голос
* подбирается под `settings.language`: ищем первый локальный голос
* с правильным `lang` префиксом (ru-RU / en-US), fallback на default.
*
* Тихий fail: если браузер/Electron не поддерживает Speech Synthesis
* (мало вероятно в Chromium) — просто ничего не делаем, не падаем.
*/
import type { Language } from '@shared/types'
const LANG_BCP47: Record<Language, string> = {
ru: 'ru-RU',
en: 'en-US'
}
let voicesLoaded = false
let cachedVoices: SpeechSynthesisVoice[] = []
function ensureVoices(): SpeechSynthesisVoice[] {
if (typeof window === 'undefined' || !('speechSynthesis' in window)) return []
if (voicesLoaded && cachedVoices.length) return cachedVoices
cachedVoices = window.speechSynthesis.getVoices()
// Chromium часто отдаёт пустой массив до voiceschanged-event'а — подпишемся
// один раз, чтобы при следующем speak() voices уже были.
if (cachedVoices.length === 0) {
window.speechSynthesis.onvoiceschanged = () => {
cachedVoices = window.speechSynthesis.getVoices()
voicesLoaded = true
}
} else {
voicesLoaded = true
}
return cachedVoices
}
function pickVoice(lang: Language): SpeechSynthesisVoice | undefined {
const target = LANG_BCP47[lang]
const voices = ensureVoices()
// 1. Точное совпадение `ru-RU` / `en-US`.
const exact = voices.find((v) => v.lang === target)
if (exact) return exact
// 2. Любой голос с правильным language-кодом (`ru`, `en`).
const partial = voices.find((v) => v.lang.startsWith(lang))
if (partial) return partial
// 3. Default — fallback на default-голос системы.
return voices.find((v) => v.default)
}
export function speak(text: string, lang: Language): void {
if (typeof window === 'undefined' || !('speechSynthesis' in window)) return
try {
// Прервать предыдущее озвучивание (если игрок быстро жмёт reminder'ы).
window.speechSynthesis.cancel()
const utter = new SpeechSynthesisUtterance(text)
const voice = pickVoice(lang)
if (voice) utter.voice = voice
utter.lang = LANG_BCP47[lang]
utter.rate = 1.0
utter.pitch = 1.0
utter.volume = 0.85
window.speechSynthesis.speak(utter)
} catch {
// Не критично — TTS опционален.
}
}

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

View File

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

View File

@@ -1,32 +1,90 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { Plus, Pause, Play, Flame, Activity, TrendingUp } from 'lucide-react' import {
Plus,
Pause,
Play,
Flame,
Activity,
TrendingUp,
Video
} from 'lucide-react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard' import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor' import { ExerciseEditor } from '../components/ExerciseEditor'
import { HistoryHeatmap } from '../components/HistoryHeatmap' import { HistoryHeatmap } from '../components/HistoryHeatmap'
import { AchievementsCard } from '../components/AchievementsCard'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import type { Exercise, HistoryEntry } from '@shared/types' import { ConfirmModal } from '../components/ui/ConfirmModal'
import type { Exercise, GameStatus, HistoryEntry } from '@shared/types'
import { formatCountdown } from '../lib/format' import { formatCountdown } from '../lib/format'
import { useT } from '../i18n' 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 { export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state) const state = useAppStore((s) => s.state)
const ticks = useAppStore((s) => s.ticks) const ticks = useAppStore((s) => s.ticks)
const [editorOpen, setEditorOpen] = useState(false) const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Exercise | null>(null) const [editing, setEditing] = useState<Exercise | null>(null)
const [deleting, setDeleting] = useState<Exercise | null>(null)
const { t, lang } = useT() const { t, lang } = useT()
const exercises = state?.exercises ?? [] // Memoise the exercises array reference so downstream useMemos don't fire
// on every render — `state?.exercises ?? []` creates a fresh array each time
// the parent re-renders even when nothing changed.
const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises])
const settings = state?.settings const settings = state?.settings
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
// Local history mirror; reloaded whenever app-state changes. // Игры: запрашиваем реальный статус (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[]>([]) const [history, setHistory] = useState<HistoryEntry[]>([])
useEffect(() => { useEffect(() => {
void window.api.getHistory().then(setHistory) const refetch = (): void => {
}, [state]) 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( const todayDone = useMemo(
() => dailyReps(history, exercises, todayKey()), () => dailyReps(history, exercises, todayKey()),
@@ -34,7 +92,11 @@ export default function Dashboard(): JSX.Element {
) )
const streak = useMemo(() => currentStreak(history), [history]) const streak = useMemo(() => currentStreak(history), [history])
// `ticks` is intentionally a dep so the countdown re-evaluates each second
// even though Date.now() inside isn't a reactive dependency. Reference it
// once inside the memo so ESLint sees the dep as used.
const stats = useMemo(() => { const stats = useMemo(() => {
void ticks // re-run on tick (Date.now() is the actual driver)
const enabled = exercises.filter((e) => e.enabled) const enabled = exercises.filter((e) => e.enabled)
const next = enabled const next = enabled
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() })) .map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
@@ -63,6 +125,9 @@ export default function Dashboard(): JSX.Element {
icon: string icon: string
intervalMinutes: number intervalMinutes: number
enabled: boolean enabled: boolean
category: import('@shared/types').ReminderCategory
dailyGoal?: number
adaptive?: boolean
}): Promise<void> { }): Promise<void> {
if (editing) await window.api.updateExercise(editing.id, draft) if (editing) await window.api.updateExercise(editing.id, draft)
else await window.api.addExercise(draft) else await window.api.addExercise(draft)
@@ -124,14 +189,18 @@ export default function Dashboard(): JSX.Element {
icon={<Flame size={14} strokeWidth={2.6} />} icon={<Flame size={14} strokeWidth={2.6} />}
/> />
<HeroStat <HeroStat
tone="info" tone={paused ? 'muted' : 'info'}
label={t('dashboard.stat.next')} label={t('dashboard.stat.next')}
// When paused, the countdown freezes — show a dash instead of a
// number that keeps ticking down, which is misleading.
value={ value={
stats.nextMs === Infinity paused
? '—' ? '—'
: stats.nextMs <= 0 : stats.nextMs === Infinity
? t('dashboard.stat.next.now') ? '—'
: formatCountdown(stats.nextMs, lang) : stats.nextMs <= 0
? t('dashboard.stat.next.now')
: formatCountdown(stats.nextMs, lang)
} }
subvalue={ subvalue={
paused paused
@@ -141,23 +210,29 @@ export default function Dashboard(): JSX.Element {
icon={<Activity size={14} strokeWidth={2.6} />} icon={<Activity size={14} strokeWidth={2.6} />}
/> />
<HeroStat <HeroStat
tone={gamesEnabled ? 'success' : 'muted'} tone={
gamesLive ? 'success' : gamesEnabledButNotLive ? 'warning' : 'muted'
}
label={t('dashboard.stat.tracking')} label={t('dashboard.stat.tracking')}
value={ value={
gamesEnabled gamesLive
? t('dashboard.stat.tracking.on') ? t('dashboard.stat.tracking.on')
: t('dashboard.stat.tracking.off') : gamesEnabledButNotLive
? t('dashboard.stat.tracking.pending')
: t('dashboard.stat.tracking.off')
} }
subvalue={ subvalue={
gamesEnabled gamesLive
? t('dashboard.stat.tracking.subtitle_on') ? t('dashboard.stat.tracking.subtitle_on')
: t('dashboard.stat.tracking.subtitle_off') : gamesEnabledButNotLive
? t('dashboard.stat.tracking.subtitle_pending')
: t('dashboard.stat.tracking.subtitle_off')
} }
icon={ icon={
<span <span
className={[ className={[
'w-1.5 h-1.5 rounded-full', 'w-1.5 h-1.5 rounded-full',
gamesEnabled ? 'bg-white' : 'bg-text/30' gamesLive ? 'bg-white' : 'bg-text/30'
].join(' ')} ].join(' ')}
/> />
} }
@@ -165,12 +240,9 @@ export default function Dashboard(): JSX.Element {
</div> </div>
{history.length > 0 && ( {history.length > 0 && (
<div className="mb-8"> <div className="mb-8 space-y-3">
<HistoryHeatmap <HistoryHeatmap history={history} exercises={exercises} />
history={history} <AchievementsCard history={history} exercises={exercises} />
exercises={exercises}
lang={lang}
/>
</div> </div>
)} )}
@@ -197,6 +269,26 @@ export default function Dashboard(): JSX.Element {
</motion.div> </motion.div>
)} )}
{!paused && meetingPaused && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 rounded-2xl bg-info/12 p-4 flex items-center gap-3"
>
<div className="w-10 h-10 rounded-xl bg-info/18 text-info grid place-items-center shrink-0">
<Video size={18} strokeWidth={2.5} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[16px] font-semibold leading-tight">
{t('dashboard.meeting.title')}
</div>
<div className="text-[14px] text-text/70 mt-1">
{t('dashboard.meeting.hint')}
</div>
</div>
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
<AnimatePresence> <AnimatePresence>
{exercises.map((ex) => ( {exercises.map((ex) => (
@@ -204,8 +296,13 @@ export default function Dashboard(): JSX.Element {
key={ex.id} key={ex.id}
exercise={ex} exercise={ex}
tick={ticks[ex.id]} tick={ticks[ex.id]}
doneToday={
ex.dailyGoal !== undefined
? repsDoneTodayForExercise(history, ex)
: undefined
}
onEdit={() => openEdit(ex)} onEdit={() => openEdit(ex)}
onDelete={() => window.api.deleteExercise(ex.id)} onDelete={() => setDeleting(ex)}
onToggle={(v) => window.api.toggleExercise(ex.id, v)} onToggle={(v) => window.api.toggleExercise(ex.id, v)}
onMarkDone={() => window.api.markDone(ex.id)} onMarkDone={() => window.api.markDone(ex.id)}
/> />
@@ -233,6 +330,19 @@ export default function Dashboard(): JSX.Element {
onClose={() => setEditorOpen(false)} onClose={() => setEditorOpen(false)}
onSave={handleSave} onSave={handleSave}
/> />
<ConfirmModal
open={deleting !== null}
title={t('exercise.delete.title')}
message={t('exercise.delete.body', { name: deleting?.name ?? '' })}
confirmLabel={t('btn.delete.confirm')}
destructive
onConfirm={() => {
if (deleting) void window.api.deleteExercise(deleting.id)
setDeleting(null)
}}
onCancel={() => setDeleting(null)}
/>
</div> </div>
</div> </div>
) )

View File

@@ -93,8 +93,13 @@ export default function Exercises(): JSX.Element {
{exercises.length === 0 && ( {exercises.length === 0 && (
<Card> <Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium"> <div className="px-5 py-12 flex flex-col items-center text-center">
{t('exercises.empty')} <div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
<Plus size={24} strokeWidth={2.5} />
</div>
<div className="text-text/65 text-[15px] font-medium max-w-xs leading-snug">
{t('exercises.empty')}
</div>
</div> </div>
</Card> </Card>
)} )}
@@ -130,9 +135,7 @@ function ExerciseRow({
<div <div
className={[ className={[
'w-9 h-9 rounded-lg grid place-items-center shrink-0', 'w-9 h-9 rounded-lg grid place-items-center shrink-0',
exercise.enabled exercise.enabled ? 'bg-accent text-white' : 'bg-text/15 text-text/45'
? 'bg-accent text-white'
: 'bg-text/15 text-text/45'
].join(' ')} ].join(' ')}
> >
<Icon name={exercise.icon} size={18} strokeWidth={2.2} /> <Icon name={exercise.icon} size={18} strokeWidth={2.2} />

View File

@@ -11,6 +11,7 @@ import {
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Spinner } from '../components/ui/Spinner'
import { Card, SectionHeader } from '../components/ui/Card' import { Card, SectionHeader } from '../components/ui/Card'
import type { GameId, GameStatus } from '@shared/types' import type { GameId, GameStatus } from '@shared/types'
import { useT } from '../i18n' import { useT } from '../i18n'
@@ -54,9 +55,7 @@ export default function GamesPage(): JSX.Element {
} }
} }
const liveCount = games.filter( const liveCount = games.filter((g) => g.enabled && g.integrationActive).length
(g) => g.enabled && g.integrationActive
).length
return ( return (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
@@ -106,7 +105,11 @@ export default function GamesPage(): JSX.Element {
))} ))}
{games.length === 0 && ( {games.length === 0 && (
<Card> <Card>
<div className="px-5 py-12 text-center text-text/55 text-[14px]"> <div
className="px-5 py-12 flex flex-col items-center gap-3 text-center text-text/55 text-[14px]"
role="status"
>
<Spinner size={22} className="text-accent" label={t('games.scanning')} />
{t('games.scanning')} {t('games.scanning')}
</div> </div>
</Card> </Card>
@@ -170,11 +173,7 @@ function GameCard({
</div> </div>
</div> </div>
{game.installed && game.integrationActive && ( {game.installed && game.integrationActive && (
<Switch <Switch checked={game.enabled} onChange={onToggle} disabled={busy} />
checked={game.enabled}
onChange={onToggle}
disabled={busy}
/>
)} )}
</div> </div>
@@ -207,7 +206,12 @@ function GameCard({
<div className="flex items-center flex-wrap gap-2 mt-4"> <div className="flex items-center flex-wrap gap-2 mt-4">
{game.installed && !game.integrationActive && ( {game.installed && !game.integrationActive && (
<Button onClick={onInstall} disabled={busy} size="sm"> <Button onClick={onInstall} disabled={busy} size="sm">
<Download size={14} strokeWidth={2.5} /> {t('btn.connect')} {busy ? (
<Spinner size={14} />
) : (
<Download size={14} strokeWidth={2.5} />
)}{' '}
{t('btn.connect')}
</Button> </Button>
)} )}
{game.integrationActive && ( {game.integrationActive && (
@@ -217,7 +221,8 @@ function GameCard({
disabled={busy} disabled={busy}
size="sm" size="sm"
> >
<Trash2 size={14} strokeWidth={2.5} /> {t('btn.disconnect')} {busy ? <Spinner size={14} /> : <Trash2 size={14} strokeWidth={2.5} />}{' '}
{t('btn.disconnect')}
</Button> </Button>
)} )}
{!game.installed && ( {!game.installed && (
@@ -280,8 +285,16 @@ function StatusBadge({
function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null { function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const { t } = useT() const { t } = useT()
// Never render in packaged builds — the matching IPC handler is also
// unregistered there, so the buttons would do nothing anyway.
if (!import.meta.env.DEV) return null
const dota = games.find((g) => g.id === 'dota2') const dota = games.find((g) => g.id === 'dota2')
if (!dota?.enabled) return null if (!dota?.enabled) return null
// In dev the preload exposes window.api.simulateMatchEnd; the conditional
// type still hides it, so we narrow here.
const api = window.api as typeof window.api & {
simulateMatchEnd?: (id: 'dota2', stats: Record<string, number>) => void
}
return ( return (
<div className="mt-10"> <div className="mt-10">
<button <button
@@ -305,7 +318,7 @@ function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
).map((p) => ( ).map((p) => (
<button <button
key={p.label} key={p.label}
onClick={() => window.api.simulateMatchEnd('dota2', p.stats)} onClick={() => api.simulateMatchEnd?.('dota2', p.stats)}
className="text-[12px] px-3 py-1.5 rounded-full bg-surface-2 hover:bg-accent/15 hover:text-accent text-text/70 font-medium transition-colors active:scale-95" className="text-[12px] px-3 py-1.5 rounded-full bg-surface-2 hover:bg-accent/15 hover:text-accent text-text/70 font-medium transition-colors active:scale-95"
> >
{p.label} {p.label}

View File

@@ -1,7 +1,13 @@
import { useEffect, useState } from 'react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card' import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard' 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 { useT } from '../i18n'
import type { import type {
Language, Language,
@@ -15,7 +21,18 @@ export default function SettingsPage(): JSX.Element {
const settings = useAppStore((s) => s.state?.settings) const settings = useAppStore((s) => s.state?.settings)
const { t } = useT() const { t } = useT()
if (!settings) if (!settings)
return <div className="p-8 text-text/45">{t('settings.loading')}</div> return (
<div
className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12 space-y-5"
role="status"
aria-label={t('settings.loading')}
>
<Skeleton className="h-10 w-56" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-24" />
</div>
)
const patch = (p: Partial<SettingsType>): void => { const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p) window.api.updateSettings(p)
@@ -76,6 +93,18 @@ export default function SettingsPage(): JSX.Element {
checked={settings.soundEnabled} checked={settings.soundEnabled}
onChange={(v) => patch({ soundEnabled: v })} onChange={(v) => patch({ soundEnabled: v })}
/> />
<ToggleRow
label={t('settings.voice.label')}
hint={t('settings.voice.hint')}
checked={settings.voicePromptsEnabled}
onChange={(v) => patch({ voicePromptsEnabled: v })}
/>
<ToggleRow
label={t('settings.meeting_pause.label')}
hint={t('settings.meeting_pause.hint')}
checked={settings.meetingAutoPause}
onChange={(v) => patch({ meetingAutoPause: v })}
/>
<SelectRow <SelectRow
label={t('settings.snooze.label')} label={t('settings.snooze.label')}
hint={t('settings.snooze.hint')} hint={t('settings.snooze.hint')}
@@ -157,11 +186,176 @@ export default function SettingsPage(): JSX.Element {
<SectionHeader title={t('settings.section.updates')} /> <SectionHeader title={t('settings.section.updates')} />
<UpdaterCard /> <UpdaterCard />
<div className="mt-6">
<SectionHeader title={t('settings.section.data')} />
<DataCard />
</div>
<div className="mt-6">
<SectionHeader title={t('settings.section.about')} />
<AboutCard />
</div>
</div> </div>
</div> </div>
) )
} }
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)
const pb = b.split('.').map(Number)
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pb[i] - pa[i]
return 0
})
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">
{t('settings.whatsnew.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.whatsnew.hint')}
</div>
</div>
<button
onClick={() => setOpen(true)}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors"
>
{t('settings.whatsnew.btn')}
</button>
</Row>
<WhatsNewModal
open={open}
versions={allVersions}
onClose={() => setOpen(false)}
/>
</Card>
)
}
function DataCard(): JSX.Element {
const { t } = useT()
// Какая операция сейчас идёт — чтобы крутить спиннер на нужной кнопке,
// а не на обеих сразу.
const [busy, setBusy] = useState<'export' | 'import' | null>(null)
const [toast, setToast] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
// Простое toast'-сообщение в карточке; через 4 сек чистится.
useEffect(() => {
if (!toast) return
const id = setTimeout(() => setToast(null), 4000)
return () => clearTimeout(id)
}, [toast])
async function onExport(): Promise<void> {
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 && !r.canceled) {
// canceled — пользователь сам передумал, тост не нужен.
setToast(t('settings.data.export.err'))
}
} finally {
setBusy(null)
}
}
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 (!r.canceled) {
// canceled — пользователь не выбрал файл, не показываем error.
setToast(t('settings.data.import.err'))
}
} finally {
setBusy(null)
}
}
return (
<Card>
<Row>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.data.export.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.data.export.hint')}
</div>
</div>
<button
onClick={onExport}
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>
<Row last>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.data.import.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.data.import.hint')}
</div>
</div>
<button
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>
{toast && (
<div className="px-4 py-2.5 text-[13px] text-text/75 bg-accent/8 truncate font-medium">
{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>
)
}
function ToggleRow({ function ToggleRow({
label, label,
hint, hint,
@@ -204,6 +398,30 @@ function QuietTimesRow({
last?: boolean last?: boolean
}): JSX.Element { }): JSX.Element {
const { t } = useT() const { t } = useT()
// Local mirror of from/to so typing doesn't fire an IPC + disk write per
// keystroke. We commit on blur (or when validation passes during typing).
// The HH:MM regex catches the moment the user has typed a full time.
const [from, setFrom] = useState(qh.from)
const [to, setTo] = useState(qh.to)
const HHMM = /^\d{1,2}:\d{2}$/
// Sync from props when an external state change happens (lang switch,
// pause toggle), but only if user isn't mid-edit.
useEffect(() => {
setFrom(qh.from)
}, [qh.from])
useEffect(() => {
setTo(qh.to)
}, [qh.to])
const commit = (next: { from?: string; to?: string }): void => {
const f = next.from ?? from
const tt = next.to ?? to
if (!HHMM.test(f) || !HHMM.test(tt)) return
if (f === qh.from && tt === qh.to) return
onChange({ ...qh, from: f, to: tt })
}
return ( return (
<Row last={last} className={disabled ? 'opacity-50' : ''}> <Row last={last} className={disabled ? 'opacity-50' : ''}>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -217,17 +435,19 @@ function QuietTimesRow({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="time" type="time"
value={qh.from} value={from}
disabled={disabled} disabled={disabled}
onChange={(e) => onChange({ ...qh, from: e.target.value })} onChange={(e) => setFrom(e.target.value)}
onBlur={() => commit({ from })}
className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num" className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num"
/> />
<span className="text-text/45 text-[14px]"></span> <span className="text-text/45 text-[14px]"></span>
<input <input
type="time" type="time"
value={qh.to} value={to}
disabled={disabled} disabled={disabled}
onChange={(e) => onChange({ ...qh, to: e.target.value })} onChange={(e) => setTo(e.target.value)}
onBlur={() => commit({ to })}
className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num" className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num"
/> />
</div> </div>
@@ -246,17 +466,19 @@ function QuietDaysRow({
disabled?: boolean disabled?: boolean
last?: boolean last?: boolean
}): JSX.Element { }): JSX.Element {
const { t, lang } = useT() const { t } = useT()
const labels = // Indices match Date.getDay() (0 = Sunday) — same convention as
lang === 'en' // src/shared/types.ts QuietHours.days values.
? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] const labels = [0, 1, 2, 3, 4, 5, 6].map((i) => t(`weekday.short.${i}`))
: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
function toggle(d: number): void { function toggle(d: number): void {
const set = new Set(qh.days) const set = new Set(qh.days)
if (set.has(d)) set.delete(d) if (set.has(d)) set.delete(d)
else set.add(d) else set.add(d)
onChange({ ...qh, days: Array.from(set).sort() }) // Numeric sort — default Array.sort() does lexical and would order
// [0,1,10,2] as [0,1,10,2]; even though days are single-digit today the
// explicit comparator survives future widening.
onChange({ ...qh, days: Array.from(set).sort((a, b) => a - b) })
} }
return ( return (

View File

@@ -1,7 +1,11 @@
import { ReactNode, useEffect, useState } from 'react' import { ReactNode, useEffect, useState } from 'react'
import { useAppStore } from '../store/appStore' import { useAppStore } from '../store/appStore'
export function ThemeProvider({ children }: { children: ReactNode }): JSX.Element { export function ThemeProvider({
children
}: {
children: ReactNode
}): JSX.Element {
const settings = useAppStore((s) => s.state?.settings) const settings = useAppStore((s) => s.state?.settings)
const [osTheme, setOsTheme] = useState<'light' | 'dark'>('dark') const [osTheme, setOsTheme] = useState<'light' | 'dark'>('dark')
@@ -21,5 +25,15 @@ export function ThemeProvider({ children }: { children: ReactNode }): JSX.Elemen
else document.documentElement.classList.remove('dark') else document.documentElement.classList.remove('dark')
}, [settings?.theme, osTheme]) }, [settings?.theme, osTheme])
// Синхронизируем <html lang> с языком приложения. Без этого screen-readers
// продолжают читать английский текст как кириллицу (или ломаются) при
// переключении на EN, и наоборот — это a11y-баг.
useEffect(() => {
const lang = settings?.language ?? 'ru'
if (document.documentElement.lang !== lang) {
document.documentElement.lang = lang
}
}, [settings?.language])
return <>{children}</> return <>{children}</>
} }

View File

@@ -31,7 +31,9 @@ export const useAppStore = create<Store>((set) => ({
export function subscribeToBackend(): () => void { export function subscribeToBackend(): () => void {
const store = useAppStore.getState() const store = useAppStore.getState()
store.hydrate() store.hydrate()
const u1 = window.api.onStateChanged((s) => useAppStore.getState().setState(s)) const u1 = window.api.onStateChanged((s) =>
useAppStore.getState().setState(s)
)
const u2 = window.api.onTick((t) => useAppStore.getState().setTicks(t)) const u2 = window.api.onTick((t) => useAppStore.getState().setTicks(t))
return () => { return () => {
u1() u1()

View File

@@ -1,3 +1,21 @@
/* Self-hosted шрифты — раньше тянулись с fonts.googleapis.com через <link>
в index.html. Минусы: внешняя зависимость (без интернета шрифты не
загружаются), CSP вынужден разрешать style-src https://fonts.googleapis.com
и font-src https://fonts.gstatic.com. Сейчас локальные .woff2 в bundle. */
@import '@fontsource/plus-jakarta-sans/400.css';
@import '@fontsource/plus-jakarta-sans/500.css';
@import '@fontsource/plus-jakarta-sans/600.css';
@import '@fontsource/plus-jakarta-sans/700.css';
@import '@fontsource/plus-jakarta-sans/800.css';
@import '@fontsource/bricolage-grotesque/500.css';
@import '@fontsource/bricolage-grotesque/600.css';
@import '@fontsource/bricolage-grotesque/700.css';
@import '@fontsource/bricolage-grotesque/800.css';
@import '@fontsource/jetbrains-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css';
@import '@fontsource/jetbrains-mono/600.css';
@import '@fontsource/jetbrains-mono/700.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -6,22 +24,22 @@
:root { :root {
/* Brand & semantic colors (iOS system palette) */ /* Brand & semantic colors (iOS system palette) */
--accent: 255 107 53; /* Apple Fitness Move orange */ --accent: 255 107 53; /* Apple Fitness Move orange */
--accent-2: 255 45 85; /* systemPink */ --accent-2: 255 45 85; /* systemPink */
--success: 52 199 89; /* systemGreen */ --success: 52 199 89; /* systemGreen */
--warning: 255 159 10; /* systemOrange dark */ --warning: 255 159 10; /* systemOrange dark */
--destructive: 255 59 48; /* systemRed */ --destructive: 255 59 48; /* systemRed */
--info: 0 122 255; /* systemBlue */ --info: 0 122 255; /* systemBlue */
color-scheme: light dark; color-scheme: light dark;
} }
/* Light — polished iOS groupedBackground with warm undertone */ /* Light — polished iOS groupedBackground with warm undertone */
:root { :root {
--bg: 245 245 249; /* slightly warmer than 242,242,247 */ --bg: 245 245 249; /* slightly warmer than 242,242,247 */
--surface: 255 255 255; --surface: 255 255 255;
--surface-2: 240 240 245; /* subtle separation for inputs/sections */ --surface-2: 240 240 245; /* subtle separation for inputs/sections */
--text: 17 17 19; /* not pure black — softer */ --text: 17 17 19; /* not pure black — softer */
--text-secondary: 60 60 67; --text-secondary: 60 60 67;
--text-tertiary: 60 60 67; --text-tertiary: 60 60 67;
--hairline: 60 60 67; --hairline: 60 60 67;
@@ -114,8 +132,8 @@ body {
} }
.font-mono-num { .font-mono-num {
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', font-family:
Menlo, monospace; 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-feature-settings: 'ss02', 'ss19', 'zero'; font-feature-settings: 'ss02', 'ss19', 'zero';
letter-spacing: -0.01em; letter-spacing: -0.01em;
@@ -212,3 +230,25 @@ body {
.dark .text-tertiary { .dark .text-tertiary {
color: rgb(var(--text-tertiary) / 0.3); color: rgb(var(--text-tertiary) / 0.3);
} }
/* ===== Reduced motion =====
framer-motion закрывает свои анимации через MotionConfig reducedMotion="user"
(см. main.tsx). Этот блок гасит CSS-анимации/переходы, которые framer не
контролирует (Tailwind animate-spin/-pulse, нативные transition). Спиннеры
намеренно НЕ обнуляем полностью — индикатор загрузки должен крутиться, иначе
пропадает смысл; но замедляем, чтобы не мелькал. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
/* Спиннеры — единственное исключение: оставляем вращение, но медленнее. */
.animate-spin {
animation-duration: 1.2s !important;
animation-iteration-count: infinite !important;
}
}

View File

@@ -11,11 +11,14 @@ export const IPC = {
updateSettings: 'settings:update', updateSettings: 'settings:update',
getAccentColor: 'system:accentColor', getAccentColor: 'system:accentColor',
getOsTheme: 'system:osTheme', getOsTheme: 'system:osTheme',
getAppVersion: 'system:appVersion',
pauseAll: 'app:pauseAll', pauseAll: 'app:pauseAll',
resumeAll: 'app:resumeAll', resumeAll: 'app:resumeAll',
quit: 'app:quit', quit: 'app:quit',
minimizeMain: 'window:minimize', minimizeMain: 'window:minimize',
toggleMaximizeMain: 'window:toggleMaximize',
isMaximizedMain: 'window:isMaximized',
closeMain: 'window:close', closeMain: 'window:close',
hideMain: 'window:hide', hideMain: 'window:hide',
@@ -36,6 +39,10 @@ export const IPC = {
markChallengeDone: 'challenge:markDone', markChallengeDone: 'challenge:markDone',
closeMatchSummary: 'matchSummary:close', closeMatchSummary: 'matchSummary:close',
// Dev-only IPC (handler ungated в prod, см. ipc.ts). Держим в enum чтобы
// main/preload/renderer не разошлись в hardcoded-строках.
devSimulateMatchEnd: 'dev:simulateMatchEnd',
// Auto-updater // Auto-updater
updaterStatus: 'updater:status', updaterStatus: 'updater:status',
updaterCheck: 'updater:check', updaterCheck: 'updater:check',
@@ -46,6 +53,10 @@ export const IPC = {
getHistory: 'history:get', getHistory: 'history:get',
clearHistory: 'history:clear', clearHistory: 'history:clear',
// Export / Import
exportState: 'state:export',
importState: 'state:import',
// events from main → renderer // events from main → renderer
evtTick: 'evt:tick', evtTick: 'evt:tick',
evtFire: 'evt:fire', evtFire: 'evt:fire',
@@ -54,5 +65,18 @@ export const IPC = {
evtThemeChanged: 'evt:themeChanged', evtThemeChanged: 'evt:themeChanged',
evtAccentChanged: 'evt:accentChanged', evtAccentChanged: 'evt:accentChanged',
evtGamesChanged: 'evt:gamesChanged', evtGamesChanged: 'evt:gamesChanged',
evtUpdaterStatus: 'evt:updaterStatus' evtUpdaterStatus: 'evt:updaterStatus',
evtMaximizeChanged: 'evt:maximizeChanged',
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 } as const

View File

@@ -58,6 +58,38 @@ describe('isQuietAt', () => {
expect(isQuietAt(qh, at('2026-05-18T13:30:00'))).toBe(true) expect(isQuietAt(qh, at('2026-05-18T13:30:00'))).toBe(true)
}) })
it('wrap-around + day filter: window day matters, not current day', () => {
// from=22:00, to=07:00, days=[Mon..Fri].
const qh: QuietHours = {
enabled: true,
from: '22:00',
to: '07:00',
days: [1, 2, 3, 4, 5]
}
// Friday 23:00 -> window starts today, Friday in filter -> quiet
expect(isQuietAt(qh, at('2026-05-15T23:00:00'))).toBe(true)
// Saturday 02:00 -> window started Friday 22:00, Friday in filter -> quiet
expect(isQuietAt(qh, at('2026-05-16T02:00:00'))).toBe(true)
// Saturday 23:00 -> would start today, Saturday not in filter -> noisy
expect(isQuietAt(qh, at('2026-05-16T23:00:00'))).toBe(false)
// Sunday 02:00 -> started Saturday, Saturday not in filter -> noisy
expect(isQuietAt(qh, at('2026-05-17T02:00:00'))).toBe(false)
// Monday 01:00 -> would have started Sunday, Sunday not in filter -> noisy
expect(isQuietAt(qh, at('2026-05-18T01:00:00'))).toBe(false)
// Monday 23:00 -> starts today, Monday in filter -> quiet
expect(isQuietAt(qh, at('2026-05-18T23:00:00'))).toBe(true)
})
it('malformed from/to falls back to non-quiet', () => {
const qh: QuietHours = {
enabled: true,
from: 'bogus',
to: '08:00',
days: ALL_DAYS
}
expect(isQuietAt(qh, at('2026-05-17T05:00:00'))).toBe(false)
})
it('zero-length window (from === to) is never quiet', () => { it('zero-length window (from === to) is never quiet', () => {
const qh: QuietHours = { const qh: QuietHours = {
enabled: true, enabled: true,

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

373
src/shared/release-notes.ts Normal file
View File

@@ -0,0 +1,373 @@
/**
* Заметки релизов для UI «Что нового». Хардкодные — синхронизируются
* с CHANGELOG.md руками при релизе. Не парсим .md в runtime: лишняя
* сложность, плюс отделение «коротких заметок для пользователя» от
* «развёрнутого технического CHANGELOG'а».
*
* Формат: версия → язык → массив пунктов (короткий заголовок + опц. деталь).
* Не более 5-7 пунктов на версию, иначе пользователь скроллит.
*/
import type { Language } from './types'
export type ReleaseNoteItem = {
/** Лаконичная строка-заголовок (≤ 60 символов). */
title: string
/** Опциональная одна строка-деталь (≤ 140 символов). */
detail?: string
/** Категория для tint'а иконки. */
tag?: 'new' | 'fix' | 'security' | 'perf'
}
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: [
{
title: 'Категории напоминаний',
detail:
'Помимо упражнений — гидратация, отдых глазам (20-20-20), осанка.',
tag: 'new'
},
{
title: 'Голосовые подсказки',
detail:
'Диктор произносит название упражнения и количество. Включается в настройках.',
tag: 'new'
},
{
title: 'Достижения',
detail:
'Milestones по количеству повторений и серий — с прогресс-баром до ближайшей цели.',
tag: 'new'
},
{
title: 'Дневная цель',
detail:
'Soft-cap на упражнение: набрал N повторов — реминдер умолкает до завтра.',
tag: 'new'
},
{
title: 'Авто-пауза на ВКС',
detail:
'Не дёргает напоминаниями, если запущен Zoom/Teams/Discord/Webex/Slack-huddle.',
tag: 'new'
},
{
title: 'Адаптивный шедулер',
detail:
'Учит часы, в которые ты честно делаешь упражнение, и сдвигает fire-ы туда.',
tag: 'new'
},
{
title: 'Экспорт и импорт',
detail:
'Резервная копия упражнений, истории и настроек в JSON — для переноса на другую машину.',
tag: 'new'
},
{
title: 'Кнопка «Что нового»',
detail: 'Это окно. Открывается автоматически после обновления.',
tag: 'new'
}
],
en: [
{
title: 'Reminder categories',
detail:
'Beyond exercises — hydration, eye rest (20-20-20), posture.',
tag: 'new'
},
{
title: 'Voice prompts',
detail:
'Speaks the exercise name and count. Toggle in Settings.',
tag: 'new'
},
{
title: 'Achievements',
detail:
'Milestones by total reps and streaks, with a progress bar to the next one.',
tag: 'new'
},
{
title: 'Daily goal',
detail:
'Soft cap per exercise: hit N reps and that reminder goes quiet until tomorrow.',
tag: 'new'
},
{
title: 'Meeting auto-pause',
detail:
'No reminders while Zoom/Teams/Discord/Webex/Slack-huddle is running.',
tag: 'new'
},
{
title: 'Adaptive scheduling',
detail:
'Learns the hours you reliably do an exercise and shifts fires into those windows.',
tag: 'new'
},
{
title: 'Export & import',
detail:
'JSON backup of exercises, history and settings — for moving to another machine.',
tag: 'new'
},
{
title: "What's new screen",
detail: 'This screen. Opens automatically after an update.',
tag: 'new'
}
]
},
'0.5.5': {
ru: [
{
title: 'Sandbox для окон',
detail: 'Окна изолированы на уровне OS — даже RCE в рендере не достанет main.',
tag: 'security'
},
{
title: 'Self-hosted шрифты',
detail:
'Plus Jakarta Sans + Bricolage Grotesque локально в bundle — работает без интернета.',
tag: 'security'
},
{
title: 'Логи для диагностики',
detail:
'%APPDATA%/Exercise Reminder/logs/latest.log — отдашь файл при проблеме.',
tag: 'new'
},
{
title: 'UI не залипает при retry-ях I/O',
tag: 'perf'
},
{
title: 'GSI больше не зависает на TIME_WAIT при выходе',
tag: 'fix'
}
],
en: [
{
title: 'Window sandbox',
detail: 'OS-level isolation — even RCE in the renderer cannot reach main.',
tag: 'security'
},
{
title: 'Self-hosted fonts',
detail:
'Plus Jakarta Sans + Bricolage Grotesque shipped in-bundle — works offline.',
tag: 'security'
},
{
title: 'Diagnostic logs',
detail:
'%APPDATA%/Exercise Reminder/logs/latest.log — share it when something breaks.',
tag: 'new'
},
{
title: 'UI no longer freezes during I/O retries',
tag: 'perf'
},
{
title: 'GSI port no longer stuck in TIME_WAIT after exit',
tag: 'fix'
}
]
},
'0.5.4': {
ru: [
{
title: 'Фоновое скачивание апдейта',
detail: 'Можно уйти на Dashboard и заниматься — апдейт качается в фоне.',
tag: 'new'
},
{
title: 'Моментальный рестарт',
detail: 'Кнопка «Рестарт» — ~1-2 сек до открытия новой версии, без диалогов NSIS.',
tag: 'new'
}
],
en: [
{
title: 'Background update download',
detail: 'You can go to Dashboard and work — the update keeps downloading.',
tag: 'new'
},
{
title: 'Instant restart',
detail: 'Restart button — ~1-2 sec to the new version, no NSIS dialogs.',
tag: 'new'
}
]
}
}
/**
* Возвращает версии, отсортированные desc, для которых есть заметки,
* с версиями, не виденными пользователем. Используется для «show whatsnew
* after update»: если пользователь прыгнул через несколько версий, показываем
* все пропущенные одним списком.
*/
export function unseenVersions(
current: string,
lastSeen: string | undefined
): string[] {
const all = Object.keys(RELEASE_NOTES).sort(compareSemverDesc)
if (!lastSeen) {
// Первый запуск этого механизма — показываем только текущую версию
// чтобы не перегружать историей. Старые версии показывает только
// явный «What's new» из Settings.
return all.filter((v) => v === current)
}
return all.filter((v) => compareSemver(v, lastSeen) > 0 && compareSemver(v, current) <= 0)
}
function parseSemver(v: string): [number, number, number] {
const parts = v.split('.').map((n) => parseInt(n, 10))
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]
}
function compareSemver(a: string, b: string): number {
const [a1, a2, a3] = parseSemver(a)
const [b1, b2, b3] = parseSemver(b)
if (a1 !== b1) return a1 - b1
if (a2 !== b2) return a2 - b2
return a3 - b3
}
function compareSemverDesc(a: string, b: string): number {
return -compareSemver(a, b)
}

View File

@@ -30,7 +30,11 @@ describe('SAMPLE_EXERCISES', () => {
expect(ex.icon.length, `icon set for ${ex.name}`).toBeGreaterThan(0) expect(ex.icon.length, `icon set for ${ex.name}`).toBeGreaterThan(0)
} }
}) })
}) })
// NB: тест «sample icons ⊆ ICON_CHOICES» лежит в
// src/renderer/src/lib/icon-choices.test.ts — он тянет renderer-сторону
// (ICON_CHOICES), а node-tsconfig сюда не пускает renderer-импорты.
describe('STAT_LABELS', () => { describe('STAT_LABELS', () => {
it('has a Russian label for every GameStat in every GAME_STATS bundle', () => { it('has a Russian label for every GameStat in every GAME_STATS bundle', () => {

View File

@@ -1,3 +1,20 @@
/**
* Категория напоминания. По умолчанию `exercise` — для совместимости со
* старыми state'ами (поле optional). Категория влияет на:
* - tint иконки в карточке (hydration синий, eyes фиолетовый и т.д.)
* - текст в окне напоминания («Время попить» вместо «Время тренировки»)
* - подсчёт повторений: для hydration/eyes/posture `reps` обычно = 1
* (это не «N раз», а просто «сделай»).
*/
export type ReminderCategory = 'exercise' | 'hydration' | 'eyes' | 'posture'
export const REMINDER_CATEGORIES: ReminderCategory[] = [
'exercise',
'hydration',
'eyes',
'posture'
]
export type Exercise = { export type Exercise = {
id: string id: string
name: string name: string
@@ -7,6 +24,22 @@ export type Exercise = {
enabled: boolean enabled: boolean
nextFireAt: number nextFireAt: number
lastDoneAt?: number lastDoneAt?: number
/** Default 'exercise' если undefined — обратная совместимость. */
category?: ReminderCategory
/**
* Опциональная дневная цель в reps. Если задана, scheduler перестаёт
* fire'ить упражнение в течение дня, когда total reps за сегодня
* (учитывая actualReps в истории) достигают `dailyGoal`. Это «soft cap»
* поверх обычного interval'а: не меняет схему таймера, просто блокирует
* fires когда цель закрыта. Завтра счётчик обнуляется (по local day).
*/
dailyGoal?: number
/**
* Адаптивный режим: scheduler анализирует исторические success/skip
* паттерны по часам и сдвигает fire'ы на «хорошие» часы. Не меняет
* базовый интервал — корректирует только timestamps.
*/
adaptive?: boolean
} }
export type NotificationMode = 'toast' | 'modal' | 'both' export type NotificationMode = 'toast' | 'modal' | 'both'
@@ -30,6 +63,18 @@ export type Settings = {
globalEnabled: boolean globalEnabled: boolean
notificationMode: NotificationMode notificationMode: NotificationMode
soundEnabled: boolean soundEnabled: boolean
/**
* TTS голос диктора в окне напоминания: «Время приседать. Десять раз».
* Полезно когда работаешь head-down (например пишешь код) — beep можно
* пропустить, голос — нет.
*/
voicePromptsEnabled: boolean
/**
* Авто-пауза напоминаний во время ВКС-звонков. Сканирует список процессов
* (Zoom/Teams/Discord/Webex/Slack-huddle/etc) раз в 30 сек, если хоть один
* запущен — fires не происходят. Чисто Windows (через tasklist).
*/
meetingAutoPause: boolean
startWithWindows: boolean startWithWindows: boolean
minimizeToTray: boolean minimizeToTray: boolean
startMinimized: boolean startMinimized: boolean
@@ -37,25 +82,63 @@ export type Settings = {
language: Language language: Language
snoozeMinutes: number snoozeMinutes: number
quietHours: QuietHours quietHours: QuietHours
/**
* Версия, для которой пользователь видел экран «Что нового». Если
* `app.getVersion()` отличается — модалка показывается при следующем
* запуске и записывает текущую версию.
*/
lastSeenVersion?: string
} }
/**
* State, видимое renderer'у (через IPC.getState и evtStateChanged).
* `history` намеренно НЕ включена — она достигает 10k записей × ~50 байт =
* 500KB JSON, и шлать её на каждый markDone/snooze/etc слишком дорого.
* Renderer запрашивает историю отдельно через `getHistory()` IPC (с опц.
* `sinceMs` для инкрементальной подгрузки).
*/
export type AppState = { export type AppState = {
exercises: Exercise[] exercises: Exercise[]
settings: Settings settings: Settings
challenges: Challenge[] challenges: Challenge[]
gamesEnabled: Partial<Record<GameId, boolean>> gamesEnabled: Partial<Record<GameId, boolean>>
}
/** Persisted shape — расширяет AppState историей (живёт только в main). */
export type PersistedState = AppState & {
history?: HistoryEntry[] history?: HistoryEntry[]
} }
export type HistoryAction = 'done' | 'skip' | 'snooze' export type HistoryAction = 'done' | 'skip' | 'snooze'
/**
* Источник записи: обычное напоминание (от scheduler'а) или матч (челлендж).
* Используется для UI («подтянулся в матче» vs «по таймеру») и аналитики.
*/
export type HistorySource = 'reminder' | 'match'
export type HistoryEntry = { export type HistoryEntry = {
/** ms epoch */ /** ms epoch */
ts: number ts: number
/**
* Для обычных напоминаний — Exercise.id. Для challenge'ей — `challenge:<id>`
* (синтетический ключ; renderer'у не нужно искать exercise по нему).
*/
exerciseId: string exerciseId: string
action: HistoryAction action: HistoryAction
/** When user did less than planned. Only meaningful for `done`. */ /** When user did less than planned. Only meaningful for `done`. */
actualReps?: number actualReps?: number
/**
* Snapshot повторений на момент записи. Гарантирует, что после удаления
* упражнения история не теряет «сколько было сделано» (раньше lookup
* `byId.get(exerciseId).reps` возвращал undefined → heatmap показывал 0).
* Для match-челленджей — фактическое число повторов из match summary.
*/
reps?: number
/** Snapshot названия упражнения/челленджа — для будущего log-view. */
name?: string
/** undefined = reminder (для обратной совместимости со старыми entries). */
source?: HistorySource
} }
export type Tick = { export type Tick = {
@@ -130,10 +213,10 @@ export type GameStatus = {
name: string name: string
installed: boolean installed: boolean
installPath?: string installPath?: string
integrationActive: boolean // cfg installed + listener running integrationActive: boolean // cfg installed + listener running
launchOption?: string // e.g. "-gamestateintegration" launchOption?: string // e.g. "-gamestateintegration"
launchOptionStatus: LaunchOptionStatus launchOptionStatus: LaunchOptionStatus
steamRunning?: boolean // helps the UI explain queued state steamRunning?: boolean // helps the UI explain queued state
enabled: boolean enabled: boolean
} }
@@ -162,6 +245,8 @@ export const DEFAULT_SETTINGS: Settings = {
globalEnabled: true, globalEnabled: true,
notificationMode: 'modal', notificationMode: 'modal',
soundEnabled: true, soundEnabled: true,
voicePromptsEnabled: false, // opt-in — на работе с коллегами может смущать
meetingAutoPause: true,
startWithWindows: false, startWithWindows: false,
minimizeToTray: true, minimizeToTray: true,
startMinimized: false, startMinimized: false,
@@ -176,40 +261,107 @@ export const DEFAULT_SETTINGS: Settings = {
} }
} }
const HHMM_RE = /^(\d{1,2}):(\d{2})$/
/** Parse `HH:MM` into minutes-since-midnight, or `null` if malformed. */
function parseHHMM(s: string): number | null {
const m = HHMM_RE.exec(s)
if (!m) return null
const h = Number(m[1])
const min = Number(m[2])
if (!Number.isFinite(h) || !Number.isFinite(min)) return null
if (h < 0 || h > 23 || min < 0 || min > 59) return null
return h * 60 + min
}
/** /**
* Returns true if `now` falls inside the quiet window. Handles wrap-around * Returns true if `now` falls inside the quiet window. Handles wrap-around
* windows (e.g. 22:00 → 08:00). Exposed from shared so both main scheduler * windows (e.g. 22:00 → 08:00) AND day-of-week filtering correctly: when the
* and renderer settings UI can use the same logic. * window started the previous day (we're in the AM half of a wrap-around),
* the day filter is evaluated against the START day, not the current day.
*
* Example: from=22:00, to=07:00, days=[Mon..Fri]. At Sat 02:00 the window
* is active (started Fri 22:00 — Friday is in the filter). At Mon 01:00 the
* window is NOT active (would have started Sun 22:00 — Sunday is excluded).
*
* Malformed `from`/`to` strings (after a corrupt state file) return false.
*/ */
export function isQuietAt(qh: QuietHours, now: Date): boolean { export function isQuietAt(qh: QuietHours, now: Date): boolean {
if (!qh.enabled) return false if (!qh.enabled) return false
const dow = now.getDay() // 0..6 const fromMin = parseHHMM(qh.from)
if (qh.days.length > 0 && !qh.days.includes(dow)) return false const toMin = parseHHMM(qh.to)
const [fh, fm] = qh.from.split(':').map(Number) if (fromMin === null || toMin === null) return false
const [th, tm] = qh.to.split(':').map(Number)
const cur = now.getHours() * 60 + now.getMinutes()
const fromMin = fh * 60 + fm
const toMin = th * 60 + tm
if (fromMin === toMin) return false if (fromMin === toMin) return false
const cur = now.getHours() * 60 + now.getMinutes()
const todayDow = now.getDay() // 0..6, 0=Sunday
const yesterdayDow = (todayDow + 6) % 7
// Helper: is this day included by the filter?
const dayActive = (dow: number): boolean =>
qh.days.length === 0 || qh.days.includes(dow)
if (fromMin < toMin) { if (fromMin < toMin) {
// Same-day window. // Same-day window — start day is `todayDow`.
if (!dayActive(todayDow)) return false
return cur >= fromMin && cur < toMin return cur >= fromMin && cur < toMin
} }
// Wraps midnight: active if after `from` today OR before `to` today. // Wrap-around window. Either:
return cur >= fromMin || cur < toMin // - cur >= fromMin: window started TODAY at fromMin → check todayDow
// - cur < toMin: window started YESTERDAY at fromMin → check yesterdayDow
if (cur >= fromMin) return dayActive(todayDow)
if (cur < toMin) return dayActive(yesterdayDow)
return false
} }
export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [ export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
{ name: 'Приседания', reps: 10, icon: 'Activity', intervalMinutes: 30, enabled: true }, {
{ name: 'Отжимания', reps: 10, icon: 'Dumbbell', intervalMinutes: 45, enabled: true }, name: 'Приседания',
{ name: 'Растяжка спины', reps: 1, icon: 'StretchHorizontal', intervalMinutes: 60, enabled: false } reps: 10,
icon: 'Activity',
intervalMinutes: 30,
enabled: true,
category: 'exercise'
},
{
name: 'Отжимания',
reps: 10,
icon: 'Dumbbell',
intervalMinutes: 45,
enabled: true,
category: 'exercise'
},
{
name: 'Стакан воды',
reps: 1,
icon: 'GlassWater',
intervalMinutes: 60,
enabled: false,
category: 'hydration'
},
{
name: 'Отдых глазам (20-20-20)',
reps: 1,
icon: 'Eye',
intervalMinutes: 20,
enabled: false,
category: 'eyes'
},
{
name: 'Проверь осанку',
reps: 1,
icon: 'PersonStanding',
intervalMinutes: 25,
enabled: false,
category: 'posture'
}
] ]
export type UpdaterStatus = export type UpdaterStatus =
| { kind: 'idle' } | { kind: 'idle'; lastCheckedAt?: number }
| { kind: 'unsupported'; reason: string } | { kind: 'unsupported'; reason: string }
| { kind: 'checking' } | { kind: 'checking' }
| { kind: 'not-available'; currentVersion: string } | { kind: 'not-available'; currentVersion: string; lastCheckedAt?: number }
| { kind: 'available'; version: string; releaseDate?: string } | { kind: 'available'; version: string; releaseDate?: string }
| { | {
kind: 'downloading' kind: 'downloading'
@@ -220,4 +372,3 @@ export type UpdaterStatus =
} }
| { kind: 'downloaded'; version: string } | { kind: 'downloaded'; version: string }
| { kind: 'error'; message: string } | { kind: 'error'; message: string }

View File

@@ -11,7 +11,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"types": ["electron-vite/node"], "types": ["electron-vite/node", "vite/client"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@shared/*": ["src/shared/*"] "@shared/*": ["src/shared/*"]

View File

@@ -14,6 +14,7 @@
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"useDefineForClassFields": true, "useDefineForClassFields": true,
"types": ["vite/client"],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@renderer/*": ["src/renderer/src/*"], "@renderer/*": ["src/renderer/src/*"],