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.
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.
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 не теряет данные
после удаления упражнения.
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.
- 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 → раздел «О приложении» → кнопка «Открыть» показывает
модалку с заметками всех релизов.
#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.
Раньше после «Скачать» 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` уже был включён — если пользователь
не нажал «Рестарт» и просто закрыл приложение, установка
произойдёт при следующем закрытии автоматически.
- Средняя кнопка тайтлбара теперь 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
(стек, архитектура, команды, релиз, тех. долг, чего не делать).
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>
Полная автоматизация релизного цикла.
== Auto-update (electron-updater) ==
- src/main/updater.ts — обёртка над autoUpdater с дискриминированным
UpdaterStatus union и broadcast через IPC. autoDownload=false,
пользователь сам жмёт «Скачать». allowDowngrade=false. Проверка
каждые 6 часов, первая через 5с после старта.
- В dev-режиме (app.isPackaged=false) статус сразу становится
'unsupported' с пояснением — никаких exceptions из updater'а.
- build.publish в package.json: provider=generic, url указывает на
Gitea release assets конкретной версии.
- src/main/ipc.ts: 4 новых канала — status/check/download/install.
- src/preload: API window.api.updater* + onUpdaterStatus.
- src/renderer/src/components/UpdaterCard.tsx: HUD-карточка в Settings
с состояниями idle/checking/available/downloading/downloaded/error,
прогресс-бар с скоростью в МБ/с.
== Тесты (vitest) ==
- vitest.config.ts с алиасами @shared / @renderer
- 23 теста, все зелёные:
* format.test.ts — formatCountdown, formatInterval (8 cases)
* vdf.test.ts — parseVdf / stringifyVdf / round-trip (11 cases)
* types.test.ts — DEFAULT_SETTINGS, SAMPLE_EXERCISES sanity (4)
- npm scripts: test (watch), test:run (CI)
== CI/CD (Gitea Actions) ==
- .gitea/workflows/ci.yml — на push/PR: typecheck + тесты + smoke-сборка
- .gitea/workflows/release.yml — на тег v*.*.*: сборка NSIS + Gitea release
== Локальный релизный скрипт ==
- scripts/release.ps1 — один скрипт от бампа версии до публикации
через Gitea API (params: -Bump patch/minor/major, -Version, -DryRun)
- npm run release — обёртка
- RELEASING.md — полная инструкция
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>