Список «Питания» разбивался на два <Card> (активные/выключенные). При
переключении строка переезжала между списками → её Switch размонтировался и
монтировался заново, и ползунок анимировался только при включении (mount
x:0→20), а при выключении — нет.
Теперь единый keyed-список (включённые сверху): Switch остаётся смонтированным,
ползунок плавно ездит в обе стороны, а строка «переезжает» в свою группу через
framer layout-анимацию. Иконке добавлен transition-colors. Глобальный Switch не
трогал — Упражнения/Дашборд не затронуты.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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.
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.
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.
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 → раздел «О приложении» → кнопка «Открыть» показывает
модалку с заметками всех релизов.
#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).
#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, который и закрыт.
#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.
#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
(стек, архитектура, команды, релиз, тех. долг, чего не делать).
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>