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>
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>
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>
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>
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>
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>
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>
Все UI-строки приложения переведены и переключаются на лету через
Settings → Язык интерфейса.
== i18n архитектура ==
- src/renderer/src/i18n/dict.ts — плоский словарь ru/en с ~190 ключами,
поддержка интерполяции {var} и плюрализации
- src/renderer/src/i18n/index.ts — useT() React hook + чистые
translate/translateN функции (для ReminderApp вне store context)
- Settings.language: 'ru' | 'en', default 'ru'
- Изменение языка применяется немедленно через Zustand reactive update
== Что переведено ==
- Sidebar nav + slogan + status
- Titlebar window controls (aria-labels)
- Dashboard: hero, 3 stat-карточки (Активных / До следующего /
Трекинг матчей), Paused banner, empty state
- Exercises: hero, секции (активные / выключенные), row meta, empty
- Challenges: hero, formula subtitle, warning, row format
«{stat} × {mult} → {exercise}», empty
- Games: hero, status badges (Live/Ready/Queued/Installed/Not found),
queued/no_user banners, dev panel
- Settings: все секции + новый Language selector
- UpdaterCard: все состояния (checking/available/downloading/
downloaded/error/idle) с интерполяцией версии и MB/s
- ReminderApp: kicker «Время тренировки», reps подпись, snooze label
с динамическими минутами, кнопки done/skip
- Match summary: победа/поражение, плюрализация «N челлендж/-а/-ей»
vs «N challenge/-s»
- Format helpers (formatCountdown, formatInterval) — теперь принимают
Language параметр
== Локалезависимая дата ==
Dashboard hero показывает today в текущей локали:
ru-RU → "воскресенье, 17 мая"
en-US → "Sunday, May 17"
== STAT_LABELS bilingual ==
- shared/types.ts: STAT_LABELS_EN + statLabel(stat, lang) helper
- ChallengeResult получил поле stat?: GameStat (для resolve на стороне
renderer'а с актуальным языком, вместо baked-in label)
- main/games/registry.ts кладёт stat в результат
== Тесты ==
- src/renderer/src/i18n/i18n.test.ts: 10 кейсов
* translate: lookup, fallback, interpolation, multi-var, lang fallback
* translateN: ru plural rules (1/21/101 → one; 2-4 → few; 0/5-20 → many)
и en (1 → one, else → many)
- Всего 33 теста зелёные
== Известное ограничение ==
SAMPLE_EXERCISES (5-6 русских "Приседания / Отжимания / ...") остаются
русскими — это seed данных на первый запуск. Английский юзер сразу
переключит язык и сможет переименовать вручную. Делать seed-per-locale
оверкилл — слишком много кода ради малого.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
6 часов было выбрано произвольно как "вежливо для сервера". На
практике слишком долго для backgound-приложения: новый релиз
доезжает до пользователя только через полдня.
Меняем на 1 час — все сравнимые приложения (Discord 30 мин,
Slack 30 мин, VS Code 1 ч) используют похожие интервалы.
Стартовая проверка (5 сек после запуска) остаётся.
Нагрузка минимальна: запрос на latest.yml = 362 байта.
UI текст «Авто-проверка раз в 6 часов» → «раз в час».
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
После того как репозиторий стал публичным, токен в Authorization
header больше не нужен. Убираю __UPDATE_TOKEN__ define из
electron.vite.config.ts и весь связанный код в updater.ts.
Преимущества:
- Никаких секретов в распространяемом .exe
- Билд не требует UPDATE_TOKEN env переменной
- Любой может склонировать и собрать без доп. конфига
== Действие пользователя ==
Если v0.3.5 .exe был скачан кем-то с публичного репо (был доступен
~30 минут до того как мы это поняли) — токен из него можно
извлечь и использовать для записи в репо. Рекомендую ротировать:
1. Gitea → Settings → Applications → удалить старый токен
2. Создать новый, скопировать
3. PowerShell:
[Environment]::SetEnvironmentVariable('GITEA_TOKEN', '<новый>', 'User')
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Эта папка содержит Claude Code workspace-конфиг (skills, agent
definitions, локальные permissions). Уже в .gitignore, но была
залита в Initial commit до того как gitignore обновился.
Перед публикацией репозитория убираем из текущего HEAD. Файлы
остаются на локальном диске (только --cached). История
по-прежнему содержит .claude/ — это не секреты (Anthropic skills
публичны, settings.local.json содержит только permission allowlist),
но для полной чистки можно переписать историю через git filter-repo.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Корень проблемы 404: репо приватный, Gitea требует Authorization
header для release assets даже на browser_download_url. Без токена
запрос возвращает 404 (не 401), поэтому electron-updater сообщал
"Cannot find channel latest.yml update info".
Решение — embed read-only токена в build:
- electron.vite.config.ts: vite `define` для __UPDATE_TOKEN__
читает process.env.UPDATE_TOKEN на этапе сборки
- src/main/updater.ts: если __UPDATE_TOKEN__ непустой, выставляет
autoUpdater.requestHeaders = { Authorization: 'token ...' }
- Декларация declare const локально в модуле, vite заменяет литерал
Сборка теперь требует:
$env:UPDATE_TOKEN = '<gitea-token>'; npm run dist
Если переменная не задана — токен пустой, auto-update тихо отключается
(статус 'unsupported' не показывается, просто запросы будут падать).
Альтернатива на будущее (без токена в .exe):
1. Сделать репо публичным в Gitea Settings
2. Или сделать только Releases публичными если в этой версии Gitea
есть такая опция
Тогда токен в коде не нужен.
== Важно для существующих пользователей ==
Установленные v0.2.x / v0.3.0-0.3.4 не могут получить апдейт
автоматически — у них в бинаре старый updater без токена и они
продолжат получать 404. Им нужно скачать v0.3.5 .exe вручную
и переустановить. После 0.3.5 auto-update заработает.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Manrope воспринимался слишком строгим и корпоративным. Замена даёт
больше характера и тёплый "попсовый" feel:
- Body/UI: Manrope → Plus Jakarta Sans (мягкие округлые формы 'a' 'g',
очень распространён в современных трендовых приложениях)
- Display/hero: Fraunces → Bricolage Grotesque (variable шрифт с opsz
axis: 24 для нормальных заголовков, 96 для hero — гротеск с
характерными слегка сжатыми формами и большим контрастом штрихов)
- Mono: JetBrains Mono без изменений
Все hero-заголовки пробампаны до 34→40px и font-bold (700), Bricolage
лучше всего смотрится в полужирном/жирном. Sidebar логотип «Laude»
тоже font-bold.
Также:
- body line-height: 1.45 → 1.5 для лучшей читаемости
- Reminder exercise name: 28→30, semibold→bold
- Match summary title: 24→26, semibold→bold
- Sidebar slogan: 12→13/medium, контраст 45→55
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Включает полный реворк UI в стиле Apple iOS/macOS:
- Geist + Instrument Serif шрифты вместо Rajdhani
- Apple HIG палитра (systemOrange, systemGreen, systemRed, true black dark)
- macOS vibrancy sidebar, iOS grouped lists, UISwitch, action sheets
- Spring анимации, active:scale press feedback
Установщик ведёт себя как install-or-update — обновляет существующую
0.2.x/0.3.0 копию с сохранением настроек.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ветка для нового интерфейса; main с предыдущим Strava-дизайном
не тронут — можно вернуться через checkout main.
== Design system (foundation) ==
- Шрифты: Geist (sans, display) + Geist Mono (HUD числа) +
Instrument Serif (hero titles) — все через Google Fonts. Близки
к SF Pro / SF Mono.
- Палитра: Apple Human Interface Guidelines
* accent: 255 107 53 (Apple Fitness Move orange)
* success: 52 199 89 (systemGreen — для switch)
* destructive: 255 59 48 (systemRed)
* info: 0 122 255 (systemBlue)
* warning: 255 159 10
* Light bg: 242 242 247 (iOS systemGroupedBackground)
* Dark bg: true black 0 0 0 (OLED-friendly), elevation через
28/44/56 grey steps как в iOS Settings
- Утилиты: .vibrancy (macOS Big Sur sidebar), .hairline-b/-t (0.5px
iOS-стиль), .shadow-card (soft layered), .font-display/-serif/-mono-num
== UI primitives ==
- Button: filled / tinted / plain / destructive / success (iOS UIButton);
active:scale-[0.97] press feedback. Старые имена primary/secondary/
ghost/danger/victory маппятся через legacyMap для совместимости.
- Switch: настоящий iOS UISwitch 51x31, spring физика knob, success
цвет on.
- Modal: центрированный sheet с rounded-3xl (22px), backdrop blur,
spring scale-in. Header с font-display, X в circle.
- Card + Row + SectionHeader: iOS grouped list — белая поверхность,
hairline-b dividers между rows, last={true} убирает последний.
== App frame ==
- Sidebar: vibrancy (semi-transparent + backdrop-blur saturate 180%),
font-serif лого, tinted icon-plaques на каждом пункте (как в iOS
Settings), плавный hover. Drawer на mobile со spring slide.
- Titlebar: центрированный title, window controls без glow, hamburger
только на <md.
- App.tsx: AnimatePresence cross-fade между маршрутами.
== Pages ==
- Dashboard: hero с font-serif Large Title + датой. 3-card Hero panel
(Apple Fitness style) с tinted icon squares. ExerciseCard теперь с
progress-ring вокруг иконки + появляющейся "Готово" pill только при
due. Three-dot menu (iOS-style popover).
- Exercises: групированный список iOS, разделение Активные/Выключенные,
chevron-right на каждом row.
- Challenges: тот же групированный паттерн + warning banner если игр
нет, formula preview карточка в редакторе с big number в accent.
- Games: cards в новом стиле, статус-чипы pulse-dot для LIVE, dev
кнопки в pill-стиле.
- Settings: классические iOS Settings секции с ToggleRow и SelectRow
inside Card. UpdaterCard полностью переработан под Cell pattern.
== Reminder window ==
- iOS action sheet: большая иконка в accent-circle сверху, font-serif
название упражнения, гигантское моноширинное число reps. Кнопки
стопкой: primary Готово full-width, потом snooze + skip в grid.
Хоткеи Enter/Space/Esc сохранены.
- Match summary: tone-цветной icon plaque (success/destructive/accent),
ChallengeRow с pill-shaped check button.
== Анимации ==
- Spring физика везде где layout (Switch knob, Modal, Sidebar drawer,
карточки)
- active:scale-[0.97] на всех интерактивных элементах (iOS touch feel)
- Cross-fade между страницами через AnimatePresence
- Никаких glow / pulse-ring — apple style это сдержанность
Verified: typecheck OK, 23 tests pass, build 36.35 KB CSS (на 6 KB
меньше предыдущего HUD-стиля), 1.56 MB JS.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Скрипт догружает уже собранные NSIS-артефакты (.exe, .blockmap,
latest.yml) в существующий Gitea release. Создаёт release если его
нет, удаляет одноимённые активы перед перезаписью.
Используется когда:
- release.ps1 успел запушить тег, но упал на загрузке
- релиз делался руками без артефактов
- нужно перезалить артефакты после пересборки
Большие файлы (>50MB) грузятся через curl.exe, потому что
Invoke-RestMethod в PS 5.1 разрывает соединение на multipart upload
80+MB. Прогресс печатается отдельно для каждого файла.
Использован для загрузки v0.3.0 артефактов.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Релиз новой спортивной палитры и адаптивной вёрстки.
- Strava orange + rose цветовая гамма
- Полная адаптивность: collapsible sidebar, drawer на mobile
- 23 unit-теста, typecheck чистый
- Установщик Exercise-Reminder-Setup-0.3.0.exe собран
Установщик ведёт себя как install-or-update: ставится на чистую
систему, обновляет существующую 0.2.x с сохранением настроек.
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>
Подготовка к auto-update и тестам.
- electron-updater для in-app апдейтов через generic provider
- vitest для unit-тестов
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>