11 Commits

Author SHA1 Message Date
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
AnRil
c9d4fc237e feat(v0.5.0): history + streak + heatmap, quiet hours, partial reps, README
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
Release / Build installer + publish release (push) Has been cancelled
== История и стрики (#1) ==
- HistoryEntry { ts, exerciseId, action: done|skip|snooze, actualReps? }
  персистится в app-state.json, лимит 10k записей (~3 года), trim oldest 10%
- markDone/snooze/skip пишут в историю; markDone принимает optional actualReps
- IPC: getHistory(sinceMs?), clearHistory(beforeTs?) + preload bindings
- Renderer helpers (src/renderer/src/lib/history.ts):
  * dayKey(ts) — YYYY-MM-DD local
  * dailyReps(entries, exs, dayKey) — суммирует actualReps || planned
  * dailyRepsRange(entries, exs, days) — для heatmap, заполняет gaps нулями
  * currentStreak(entries) — consecutive days, today или yesterday (grace)
- Dashboard теперь 4 hero-карточки: Today (повторов за день) / Streak
  (дней подряд) / Next / Tracking
- Новый компонент HistoryHeatmap — GitHub-style 12-недельный календарь
  с 5 интенсивностями, локализованными подписями дней/месяцев

== Тихие часы (#2) ==
- shared/types.ts: QuietHours { enabled, from, to, days[] } + isQuietAt()
  helper с правильной обработкой wrap-around окон (22:00→08:00)
- DEFAULT_SETTINGS.quietHours = disabled, 22:00→08:00, все дни
- main/scheduler.ts: проверка isQuietAt перед fire; deferred fires
  поднимаются после окончания окна
- Settings UI: новая секция "Тихие часы" с toggle, time-pickers,
  day-of-week pills

== Сделал частично (#3) ==
- ReminderApp: stepper [−][число][+] вокруг счётчика повторов
- При adjusted (actualReps !== exercise.reps) число подсвечивается accent
  и появляется подпись "Засчитаем X из Y"
- markDone передаёт actualReps только если юзер реально изменил —
  иначе undefined чтобы история фиксировала планируемое значение чисто

== README.md (#4) ==
- Описание, фичи, скриншоты (TODO-плейсхолдер), установка, dev-команды,
  архитектура, тесты, stack, ссылка на RELEASING.md
- Бэйджи version / tests / platform

== i18n ==
- ~14 новых ключей × 2 языка: dashboard.stat.today_done, streak,
  settings.quiet.* (3 row'а), reminder.partial

== Тесты — 51 (было 33) ==
- shared/quiet-hours.test.ts (5): disabled, same-day, wrap-around,
  day filtering, zero-length
- renderer/lib/history.test.ts (13): dayKey, dailyReps (planned vs
  actual vs ignore non-done), currentStreak (empty, today gap,
  consecutive, yesterday grace, multi-entry same day), dailyRepsRange

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:41:13 +07:00
AnRil
973339ca62 feat(i18n): bilingual UI (Russian + English) + language selector
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
Release / Build installer + publish release (push) Has been cancelled
Все 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>
2026-05-17 23:28:34 +07:00
62 changed files with 6751 additions and 899 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"
}
}
]
}

96
CHANGELOG.md Normal file
View File

@@ -0,0 +1,96 @@
# Changelog
Все заметные изменения проекта документируются здесь.
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект следует [Semantic Versioning](https://semver.org/lang/ru/).
## [Unreleased]
### Added
- Prettier + ESLint конфиги, скрипты `npm run format` / `npm run lint`.
- `.editorconfig` для единообразного оформления между редакторами.
## [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://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.1...HEAD
[0.5.1]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.1
[0.5.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.0
[0.4.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.4.0
[0.2.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.2.0

95
README.md Normal file
View File

@@ -0,0 +1,95 @@
# Laude — Exercise Reminder
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
[![release](https://img.shields.io/badge/release-v0.5.1-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
[![tests](https://img.shields.io/badge/tests-51%20passing-green)]()
[![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]()
## Что внутри
- **Гибкие напоминания** — любое количество упражнений, интервал от минуты до часов, разные иконки.
- **История и стрики** — heatmap-календарь активности, ежедневный счётчик, серия дней подряд.
- **Тихие часы** — окно времени когда напоминания подавляются (например `22:00 → 08:00`), с выбором дней недели.
- **Сделал частично** — степпер `/+` в окне напоминания: если ты сделал 5 из 10, в историю запишется честное число.
- **Игровая интеграция (Dota 2)** — Game State Integration читает статистику матча, после Победа/Поражение показывает экран с «причитающимися» повторениями (например `10 смертей × 3 = 30 приседаний`).
- **Apple-style интерфейс** — Plus Jakarta Sans + Bricolage Grotesque, iOS-палитра, vibrancy sidebar, spring-анимации, светлая/тёмная/системная тема.
- **Два языка** — русский и английский, переключение мгновенное.
- **Auto-update** — приложение само скачивает новые версии из фиксированного `update-channel` (проверка каждый час, силент-ретрай при сетевых сбоях).
## Скриншоты
> _TODO: вставить screenshots Dashboard / Reminder / Match summary (light + dark)._
## Установка
Скачай последний `Exercise-Reminder-Setup-X.Y.Z.exe` со страницы релизов и запусти. Установщик:
- Создаёт ярлык на рабочем столе и в Пуске
- Сохраняет настройки в `%APPDATA%\Exercise Reminder\`
- При запуске поверх существующей инсталляции — обновляет, настройки сохраняются
Windows SmartScreen может предупредить «не доверено» — приложение не подписано code-signing сертификатом. Нажми `Подробнее``Выполнить в любом случае`.
## Разработка
```bash
git clone https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude.git
cd laude
npm install
npm run dev
```
Полезные команды:
```bash
npm run typecheck # tsc по main + renderer
npm run test # vitest в watch-режиме
npm run test:run # vitest один раз (для CI)
npm run build # сборка без NSIS
npm run dist # сборка + NSIS-инсталлятор → release/
npm run release -- -Bump patch # bump версии + tag + push + upload в Gitea
```
Документ `RELEASING.md` описывает процесс выпуска новых версий.
## Архитектура
- **Electron 33** — multi-process: main (Node/scheduler/GSI) + preload (contextBridge) + renderer (React)
- **Renderer** — React 18, TypeScript 5, Vite 5, Tailwind 3, framer-motion, react-router, zustand
- **Persistence** — единственный JSON-файл `%APPDATA%\Exercise Reminder\app-state.json` (debounced writes)
- **IPC** — типизированные каналы через `src/shared/ipc.ts`, обёрнуто preload-ом
- **i18n** — самописная микро-система: `src/renderer/src/i18n/dict.ts` (плоский словарь ~200 ключей × 2 языка) + хук `useT()`
- **Auto-update** — `electron-updater` с `generic` provider, манифест `latest.yml` лежит в Gitea release attachments
- **GSI Dota 2** — локальный HTTP-сервер слушает GameStateIntegration коллбэки от Steam, парсит match-end events
## Тесты
```
src/shared/types.test.ts (4)
src/shared/quiet-hours.test.ts (5)
src/renderer/src/lib/format.test.ts (8)
src/renderer/src/lib/history.test.ts (13)
src/main/games/vdf.test.ts (11)
src/renderer/src/i18n/i18n.test.ts (10)
─────────────────────────────────────────
51 ✓
```
Покрытие: чистые helpers (форматирование, история/стрики, тихие часы, парсер VDF для Steam-конфигов), i18n с плюрализацией для RU/EN, дефолты shared-типов.
## Лицензия
Пока не указана. По умолчанию все права защищены. Если хочешь форк/использование — открой issue.
## Stack
- [Electron](https://www.electronjs.org/) · runtime
- [electron-vite](https://electron-vite.org/) · build
- [React](https://react.dev/) + [TypeScript](https://www.typescriptlang.org/)
- [Tailwind CSS](https://tailwindcss.com/) · стили
- [framer-motion](https://motion.dev/) · анимации
- [lucide-react](https://lucide.dev/) · иконки
- [electron-updater](https://www.electron.build/auto-update) · auto-update
- [Vitest](https://vitest.dev/) · тесты
- Шрифты: [Plus Jakarta Sans](https://fonts.google.com/specimen/Plus+Jakarta+Sans), [Bricolage Grotesque](https://fonts.google.com/specimen/Bricolage+Grotesque), [JetBrains Mono](https://fonts.google.com/specimen/JetBrains+Mono)

View File

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

2708
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "laude",
"version": "0.3.7",
"version": "0.5.2",
"description": "Exercise reminder — Windows desktop app",
"main": "out/main/index.js",
"author": "AnRil",
@@ -14,6 +14,9 @@
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"test": "vitest",
"test:run": "vitest run",
"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:dir": "electron-vite build && electron-builder --win --x64 --dir",
"publish": "electron-vite build && electron-builder --win --x64 --publish always",
@@ -33,12 +36,18 @@
"@types/node": "^22.19.19",
"@types/react": "^18.3.12",
"@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",
"autoprefixer": "^10.4.20",
"electron": "^33.2.0",
"electron-builder": "^25.1.8",
"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",
"prettier": "^3.4.1",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^5.4.11",
@@ -88,7 +97,7 @@
},
"publish": {
"provider": "generic",
"url": "https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/download/v${version}",
"url": "https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/download/update-channel",
"channel": "latest"
}
}

View File

@@ -1,67 +1,78 @@
<#
.SYNOPSIS
Локальный релиз: бамп версии коммит тег push сборка upload в Gitea release.
Локальный релиз: бамп версии -> коммит -> тег -> push -> сборка -> upload в Gitea.
.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
Какую часть semver инкрементировать: patch (по умолчанию), minor, major.
Альтернатива — указать -Version явно.
.PARAMETER Version
Точная версия (напр. "0.3.0"). Если задана, -Bump игнорируется.
Точная версия (напр. "0.5.1"). Если задана, -Bump игнорируется.
.PARAMETER SkipBuild
Пропустить сборку (если уже собрано вручную, .exe лежит в release/).
.PARAMETER BridgeTags
Список старых тегов, в которые нужно ТАКЖЕ перезалить новые артефакты,
чтобы пользователи на этих версиях нашли апдейт. Например: v0.4.0,v0.5.0.
.PARAMETER DryRun
Показать что произойдёт, но ничего не делать.
Показать что произойдёт, ничего не делая.
.EXAMPLE
pwsh scripts/release.ps1 -Bump minor
pwsh scripts/release.ps1 -Version 0.3.0
pwsh scripts/release.ps1 -Bump patch -DryRun
pwsh scripts/release.ps1 -Bump patch
pwsh scripts/release.ps1 -Version 0.5.1 -BridgeTags v0.4.0,v0.5.0
.NOTES
Требует переменную окружения GITEA_TOKEN с правом write:repository
(создаётся в Gitea: Settings → Applications → Generate New Token).
Требует GITEA_TOKEN с правом write:repository.
Канал 'update-channel' должен существовать на Gitea (создаётся однократно).
#>
param(
[ValidateSet('patch', 'minor', 'major')]
[string]$Bump = 'patch',
[string]$Version,
[switch]$SkipBuild,
[string[]]$BridgeTags = @(),
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
# --- Config ---------------------------------------------------------------
$repoOwner = 'AnRil'
$repoName = 'laude'
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git'
$apiBase = "https://$giteaHost/api/v1"
$channelTag = 'update-channel'
# --- Pre-flight checks ---------------------------------------------------
# --- Pre-flight ----------------------------------------------------------
$root = Resolve-Path (Join-Path $PSScriptRoot '..')
Set-Location $root
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
}
$status = git status --porcelain
if ($status) {
Write-Error "Есть незакоммиченные изменения. Сначала закоммить или stash."
Write-Error "Uncommitted changes. Commit or stash first."
exit 1
}
$branch = git rev-parse --abbrev-ref HEAD
if ($branch -ne 'main') {
Write-Warning "Текущая ветка не main, а $branch. Продолжить? (Ctrl+C для отмены)"
Read-Host 'Press Enter'
Write-Warning "Branch is $branch, not main. Press Enter to continue or Ctrl+C to cancel."
Read-Host
}
# --- Compute next version ------------------------------------------------
@@ -83,109 +94,75 @@ if ($Version) {
$tag = "v$next"
Write-Host ""
Write-Host "Release plan" -ForegroundColor Cyan
Write-Host " current : v$current"
Write-Host " next : $tag"
Write-Host " bump : $Bump"
Write-Host "Release plan" -ForegroundColor Cyan
Write-Host " current : v$current"
Write-Host " next : $tag"
Write-Host " publish into : $tag, $channelTag$(if ($BridgeTags) { ', ' + ($BridgeTags -join ', ') })"
Write-Host ""
if ($DryRun) {
Write-Host '(dry run exiting)' -ForegroundColor Yellow
Write-Host '(dry run - exiting)' -ForegroundColor Yellow
exit 0
}
# --- Bump version in package.json ---------------------------------------
Write-Host "→ Bumping package.json to $next" -ForegroundColor Cyan
$pkgJson = (Get-Content package.json -Raw) -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`""
Set-Content -Path package.json -Value $pkgJson -NoNewline -Encoding utf8
# --- Bump package.json --------------------------------------------------
# IMPORTANT: read+write as UTF-8 WITHOUT BOM. PS5.1 defaults will (a) read the
# file as CP1251 and mangle non-ASCII chars like em-dash, and (b) write back
# 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 commit -m "chore(release): $tag"
# --- Build (typecheck + tests + dist) ------------------------------------
# --- Quality gates ------------------------------------------------------
if (-not $SkipBuild) {
Write-Host "→ Running typecheck" -ForegroundColor Cyan
Write-Host "Typecheck..." -ForegroundColor Cyan
npm run typecheck
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "→ Running tests" -ForegroundColor Cyan
Write-Host "Tests..." -ForegroundColor Cyan
npm run test:run
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Building installer (npm run dist)…" -ForegroundColor Cyan
Write-Host "Building installer..." -ForegroundColor Cyan
npm run dist
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# --- Verify artifacts exist ---------------------------------------------
# --- Verify artifacts ---------------------------------------------------
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$next.exe"
$blockmap = "$installer.blockmap"
$manifest = Join-Path 'release' 'latest.yml'
foreach ($f in @($installer, $blockmap, $manifest)) {
if (-not (Test-Path $f)) {
Write-Error "Не найден артефакт: $f"
Write-Error "Artifact missing: $f"
exit 1
}
}
# --- Tag + push ----------------------------------------------------------
Write-Host "Tagging $tag and pushing" -ForegroundColor Cyan
# --- Tag + push ---------------------------------------------------------
Write-Host "Tagging $tag and pushing..." -ForegroundColor Cyan
git tag -a $tag -m "Release $tag"
git push origin main
git push origin $tag
# --- Create release via Gitea API ----------------------------------------
Write-Host "→ Creating Gitea release $tag" -ForegroundColor Cyan
$headers = @{
Authorization = "token $env:GITEA_TOKEN"
Accept = 'application/json'
}
# --- Upload to all target releases --------------------------------------
$uploadScript = Join-Path $PSScriptRoot 'upload-release-assets.ps1'
# Release notes from commits since previous tag
$prev = git describe --tags --abbrev=0 "$tag^" 2>$null
if ($prev) {
$log = git log --pretty=format:"- %s" "$prev..$tag" | Out-String
} else {
$log = git log --pretty=format:"- %s" "$tag" | Out-String
}
$body = @"
### Изменения
$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
$targets = @($tag, $channelTag) + $BridgeTags
foreach ($target in $targets) {
Write-Host ""
Write-Host "==> Uploading $next artifacts into release '$target'" -ForegroundColor Cyan
& powershell -ExecutionPolicy Bypass -File $uploadScript -Tag $target -AssetVersion $next
if ($LASTEXITCODE -ne 0) {
Write-Error "Upload to '$target' failed (exit $LASTEXITCODE)"
exit $LASTEXITCODE
}
}
$releaseUrl = "https://$giteaHost/$repoOwner/$repoName/releases/tag/$tag"
@@ -193,4 +170,4 @@ Write-Host ""
Write-Host "Release published" -ForegroundColor Green
Write-Host " $releaseUrl"
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,22 +1,31 @@
<#
.SYNOPSIS
Upload pre-built NSIS artifacts to an existing Gitea release.
Upload pre-built NSIS artifacts to a Gitea release.
.DESCRIPTION
Use when the tag v* is already pushed (e.g. release.ps1 succeeded up to
push but failed on upload, or release was created manually without assets).
If a release for the tag does not exist yet, it is created. If it exists,
same-name assets are replaced.
Uploads installer + blockmap + latest.yml to the release identified by -Tag.
If the release does not exist it is created (only for semver-looking tags;
for non-semver tags like 'update-channel' the release must exist already).
Same-named existing assets are replaced.
.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
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(
[string]$Tag
[string]$Tag,
[string]$AssetVersion
)
$ErrorActionPreference = 'Stop'
@@ -35,10 +44,18 @@ $root = Resolve-Path (Join-Path $PSScriptRoot '..')
Set-Location $root
if (-not $Tag) {
$version = (Get-Content package.json | ConvertFrom-Json).version
$Tag = "v$version"
$pkgVersion = (Get-Content package.json | ConvertFrom-Json).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"
$blockmap = "$installer.blockmap"
@@ -66,6 +83,10 @@ try {
Write-Host " Found release id=$($release.id)" -ForegroundColor DarkGray
} catch {
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
$prev = $null
@@ -80,7 +101,7 @@ try {
if ($prev) {
$log = (& git log --pretty=format:"- %s" "$prev..$Tag") -join "`n"
} 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"
}
$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)) {
$name = Split-Path $asset -Leaf
$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))"
# -f: fail on HTTP errors; -s -S: silent but show errors; --data-binary @file
& $curl `
--fail-with-body `
--silent --show-error `
-H "Authorization: token $env:GITEA_TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$asset" `
$uri
if ($LASTEXITCODE -ne 0) {
Write-Error "Upload failed for $name (curl exit $LASTEXITCODE)"
$attempt = 0
$uploaded = $false
while (-not $uploaded -and $attempt -le $maxRetries) {
if ($attempt -gt 0) {
$wait = $backoffs[[Math]::Min($attempt - 1, $backoffs.Length - 1)]
Write-Host (" Retrying in {0}s (attempt {1}/{2})..." -f $wait, ($attempt + 1), ($maxRetries + 1)) -ForegroundColor Yellow
Start-Sleep -Seconds $wait
# 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
}
}

View File

@@ -17,5 +17,8 @@ export function isAutostartEnabled(): 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 { randomBytes } from 'node:crypto'
import { randomBytes, timingSafeEqual } from 'node:crypto'
import { app } from 'electron'
import type { GameProvider, ProviderEventHandler } from './provider'
import { findGameInstall } from './steam'
@@ -21,6 +27,7 @@ const LAUNCH_OPTION = '-gamestateintegration'
type DotaGsi = {
provider?: { name?: string }
auth?: { token?: string }
map?: {
game_state?: string
win_team?: 'radiant' | 'dire' | 'none'
@@ -38,6 +45,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 {
return join(app.getPath('userData'), 'dota2-gsi-token.txt')
}
@@ -115,7 +135,10 @@ export class Dota2Provider implements GameProvider {
if (present) launchOptionStatus = 'applied'
else {
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 {
@@ -134,7 +157,8 @@ export class Dota2Provider implements GameProvider {
async install(): Promise<void> {
if (!this.installPath) {
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!)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
@@ -157,7 +181,13 @@ export class Dota2Provider implements GameProvider {
async start(emit: ProviderEventHandler): Promise<void> {
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> {
@@ -169,10 +199,34 @@ export class Dota2Provider implements GameProvider {
}
private handle(g: DotaGsi): void {
// 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 } }
// 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)
) {
return
}
const state = g.map?.game_state ?? this.latest?.map?.game_state
// 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
const prev = this.prevState
@@ -209,6 +263,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,63 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse
} from 'node:http'
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
/**
* 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
const handlers: Map<string, GsiHandler> = new Map()
function getBody(req: IncomingMessage): Promise<Buffer> {
function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => {
let received = 0
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('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 handler = handlers.get(route)
if (!handler) {
@@ -29,17 +70,38 @@ async function onRequest(req: IncomingMessage, res: ServerResponse): Promise<voi
res.end()
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 {
const body = await getBody(req)
const body = await readBody(req)
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.
console.warn('[gsi] bad request:', err instanceof Error ? err.message : err)
res.statusCode = 400
res.end()
return
}
try {
handler(payload, req.headers)
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('ok')
} catch (err) {
console.error('[gsi] handler threw:', err)
res.statusCode = 500
res.end(String(err))
res.end()
}
}
@@ -52,16 +114,26 @@ export async function startGsiServer(): Promise<void> {
})
}
export function stopGsiServer(): void {
if (server) {
server.close()
server = null
}
export async function stopGsiServer(): Promise<void> {
if (!server) return
const s = server
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)
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 {

View File

@@ -6,7 +6,10 @@ export type MatchEndPayload = {
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 {
readonly id: GameId

View File

@@ -5,7 +5,6 @@ import { startGsiServer, stopGsiServer } from './gsi-server'
import { onLaunchOptionsApplied } from './steam-launch-options'
import { IPC } from '@shared/ipc'
import type {
Challenge,
ChallengeResult,
GameId,
GameStatus,
@@ -21,7 +20,10 @@ const providers: Record<GameId, GameProvider> = {
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 challenges = getChallenges().filter(
(c) => c.gameId === gameId && c.enabled
@@ -38,7 +40,8 @@ async function onMatchEnd(gameId: GameId, payload: MatchEndPayload): Promise<voi
exerciseName: ch.exerciseName,
reps,
statValue,
statLabel: STAT_LABELS[ch.stat]
statLabel: STAT_LABELS[ch.stat],
stat: ch.stat
})
}
if (results.length === 0) return
@@ -135,7 +138,10 @@ export function broadcastGames(games: GameStatus[]): void {
}
// 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, {
durationMs: (stats.duration_min ?? 35) * 60_000,
won: stats.won === 1,

View File

@@ -4,6 +4,7 @@ import {
existsSync,
readFileSync,
readdirSync,
renameSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
@@ -53,7 +54,10 @@ function findKey(node: VdfNode, target: string): string | undefined {
return undefined
}
function findCaseInsensitive(node: VdfNode, ...keys: string[]): VdfNode | undefined {
function findCaseInsensitive(
node: VdfNode,
...keys: string[]
): VdfNode | undefined {
let cur: VdfNode = node
for (const key of keys) {
const found: string | undefined = findKey(cur, key)
@@ -80,7 +84,11 @@ function findOrCreatePath(node: VdfNode, ...keys: string[]): VdfNode {
return cur
}
function getAppNode(parsed: VdfNode, appId: string, create: boolean): VdfNode | undefined {
function getAppNode(
parsed: VdfNode,
appId: string,
create: boolean
): VdfNode | undefined {
if (create) {
const apps = findOrCreatePath(
parsed,
@@ -116,13 +124,28 @@ function writeBackup(path: string): void {
}
function atomicWrite(path: string, contents: string): 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).
const tmp = path + '.exr.tmp'
writeFileSync(tmp, contents, 'utf-8')
// fs.renameSync replaces destination atomically on Windows
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('node:fs') as typeof import('node:fs')
fs.renameSync(tmp, path)
const delays = [0, 50, 200]
let lastErr: unknown
for (const delay of delays) {
if (delay > 0) {
const until = Date.now() + delay
while (Date.now() < until) {
/* spin */
}
}
try {
writeFileSync(tmp, contents, 'utf-8')
renameSync(tmp, path)
return
} catch (e) {
lastErr = e
}
}
throw lastErr
}
function modifyLaunchOptions(
@@ -183,7 +206,9 @@ export async function isLaunchOptionPresent(
const parsed = parseVdf(raw)
const app = getAppNode(parsed, appId, false)
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
const value = String(app[loKey] ?? '')
if (value.includes(option)) return true
@@ -194,7 +219,10 @@ export async function isLaunchOptionPresent(
return false
}
async function applyOptionToAllConfigs(appId: string, option: string): Promise<void> {
async function applyOptionToAllConfigs(
appId: string,
option: string
): Promise<void> {
const paths = await getLocalConfigPaths()
for (const p of paths) {
modifyLaunchOptions(p, appId, (current) => {
@@ -204,7 +232,10 @@ async function applyOptionToAllConfigs(appId: string, option: string): Promise<v
}
}
async function removeOptionFromAllConfigs(appId: string, option: string): Promise<void> {
async function removeOptionFromAllConfigs(
appId: string,
option: string
): Promise<void> {
const paths = await getLocalConfigPaths()
for (const p of paths) {
modifyLaunchOptions(p, appId, (current) => {

View File

@@ -6,12 +6,14 @@ import { parseVdf, type VdfNode } from './vdf'
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 {
const { stdout } = await execAsync(
`reg query "${key}" /v ${valueName}`,
{ windowsHide: true }
)
const { stdout } = await execAsync(`reg query "${key}" /v ${valueName}`, {
windowsHide: true
})
const m = stdout.match(/REG_SZ\s+(.+)/)
return m?.[1]?.trim()
} catch {

View File

@@ -4,7 +4,10 @@
export type VdfNode = { [key: string]: string | VdfNode }
class Cursor {
constructor(public src: string, public pos: number = 0) {}
constructor(
public src: string,
public pos: number = 0
) {}
peek(): string {
return this.src[this.pos] ?? ''
}
@@ -51,7 +54,12 @@ function readToken(c: Cursor): string {
}
if (c.peek() === '{' || c.peek() === '}') return c.next()
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()
}
return out

View File

@@ -1,5 +1,9 @@
import { app, BrowserWindow, nativeTheme, systemPreferences } from 'electron'
import { createMainWindow, createReminderWindow, showMainWindow } from './windows'
import {
createMainWindow,
createReminderWindow,
showMainWindow
} from './windows'
import { registerIpc } from './ipc'
import { startScheduler, stopScheduler } from './scheduler'
import { createTray } from './tray'
@@ -27,8 +31,7 @@ if (!gotLock) {
registerIpc()
createTray()
const hidden =
wasStartedHidden() || getState().settings.startMinimized
const hidden = wasStartedHidden() || getState().settings.startMinimized
createMainWindow(!hidden)
// Pre-create the reminder window so first-trigger is instant (no load lag).
createReminderWindow()
@@ -51,7 +54,8 @@ if (!gotLock) {
try {
const color = '#' + systemPreferences.getAccentColor()
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 {
// ignore

View File

@@ -1,11 +1,20 @@
import { ipcMain, nativeTheme, systemPreferences, BrowserWindow, app, shell } from 'electron'
import {
ipcMain,
nativeTheme,
systemPreferences,
BrowserWindow,
app,
shell
} from 'electron'
import { IPC } from '@shared/ipc'
import type { Challenge, Exercise, GameId, Settings } from '@shared/types'
import type { Exercise, GameId, Settings } from '@shared/types'
import {
addChallenge,
addExercise,
clearHistory,
deleteChallenge,
deleteExercise,
getHistory,
getState,
markDone,
setGameEnabled,
@@ -19,6 +28,7 @@ import { broadcastState } from './state-actions'
import { setAutostart, isAutostartEnabled } from './autostart'
import { setPaused, forceCheck } from './scheduler'
import { hideReminderWindow, getMainWindow } from './windows'
import { refreshMenu } from './tray'
import {
broadcastGames,
installGame,
@@ -33,6 +43,16 @@ import {
getUpdaterStatus,
quitAndInstall
} from './updater'
import {
validateActualReps,
validateChallengeInput,
validateChallengePatch,
validateExerciseInput,
validateExercisePatch,
validateId,
validateSettingsPatch,
validateSnoozeMinutes
} from './validate'
export function registerIpc(): void {
ipcMain.handle(IPC.getState, () => {
@@ -41,57 +61,78 @@ export function registerIpc(): void {
return state
})
ipcMain.handle(IPC.addExercise, (_e, input: unknown) => {
const safe = validateExerciseInput(input)
if (!safe) return null
const ex = addExercise(safe)
broadcastState()
return ex
})
ipcMain.handle(
IPC.addExercise,
(_e, input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>) => {
const ex = addExercise(input)
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()
return ex
}
)
ipcMain.handle(IPC.updateExercise, (_e, id: string, patch: Partial<Exercise>) => {
const ex = updateExercise(id, patch)
broadcastState()
return ex
})
ipcMain.handle(IPC.deleteExercise, (_e, id: string) => {
ipcMain.handle(IPC.deleteExercise, (_e, idRaw: unknown) => {
const id = validateId(idRaw)
if (!id) return false
const ok = deleteExercise(id)
broadcastState()
return ok
})
ipcMain.handle(IPC.toggleExercise, (_e, id: string, enabled: boolean) => {
const patch: Partial<Exercise> = { enabled }
if (enabled) {
const ex = getState().exercises.find((e) => e.id === id)
if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
ipcMain.handle(
IPC.toggleExercise,
(_e, idRaw: unknown, enabledRaw: unknown) => {
const id = validateId(idRaw)
if (!id || typeof enabledRaw !== 'boolean') return null
const patch: Partial<Exercise> = { enabled: enabledRaw }
if (enabledRaw) {
const ex = getState().exercises.find((e) => e.id === id)
if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
}
const ex = updateExercise(id, patch)
broadcastState()
return ex
}
const ex = updateExercise(id, patch)
)
ipcMain.handle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => {
const id = validateId(idRaw)
if (!id) return null
const ex = markDone(id, validateActualReps(repsRaw))
broadcastState()
return ex
})
ipcMain.handle(IPC.markDone, (_e, id: string) => {
const ex = markDone(id)
broadcastState()
return ex
})
ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => {
ipcMain.handle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => {
const id = validateId(idRaw)
const minutes = validateSnoozeMinutes(minRaw)
if (!id || minutes === null) return null
const ex = snooze(id, minutes)
broadcastState()
return ex
})
ipcMain.handle(IPC.skip, (_e, id: string) => {
ipcMain.handle(IPC.skip, (_e, idRaw: unknown) => {
const id = validateId(idRaw)
if (!id) return null
const ex = skip(id)
broadcastState()
return ex
})
ipcMain.handle(IPC.updateSettings, (_e, patch: Partial<Settings>) => {
ipcMain.handle(IPC.updateSettings, (_e, patchRaw: unknown) => {
const patch = validateSettingsPatch(patchRaw)
if (!patch) return null
if (patch.startWithWindows !== undefined) {
setAutostart(patch.startWithWindows)
}
@@ -101,9 +142,21 @@ export function registerIpc(): void {
}
const settings = updateSettings(merged)
broadcastState()
// Language change reflects in the tray menu next time it's opened.
if (patch.language !== undefined) refreshMenu()
return settings
})
ipcMain.handle(IPC.pauseAll, () => {
setPaused(true)
refreshMenu()
})
ipcMain.handle(IPC.resumeAll, () => {
setPaused(false)
forceCheck()
refreshMenu()
})
ipcMain.handle(IPC.getAccentColor, () => {
try {
return '#' + systemPreferences.getAccentColor()
@@ -116,12 +169,6 @@ export function registerIpc(): void {
nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
)
ipcMain.handle(IPC.pauseAll, () => setPaused(true))
ipcMain.handle(IPC.resumeAll, () => {
setPaused(false)
forceCheck()
})
ipcMain.handle(IPC.quit, () => app.quit())
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
@@ -174,43 +221,65 @@ export function registerIpc(): void {
})
// Challenges
ipcMain.handle(IPC.addChallenge, (_e, input: Omit<Challenge, 'id'>) => {
const c = addChallenge(input)
ipcMain.handle(IPC.addChallenge, (_e, input: unknown) => {
const safe = validateChallengeInput(input)
if (!safe) return null
const c = addChallenge(safe)
broadcastState()
return c
})
ipcMain.handle(
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)
broadcastState()
return c
}
)
ipcMain.handle(IPC.deleteChallenge, (_e, id: string) => {
ipcMain.handle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
const id = validateId(idRaw)
if (!id) return false
const ok = deleteChallenge(id)
broadcastState()
return ok
})
ipcMain.handle(IPC.toggleChallenge, (_e, id: string, enabled: boolean) => {
const c = updateChallenge(id, { enabled })
broadcastState()
return c
})
ipcMain.handle(
IPC.toggleChallenge,
(_e, idRaw: unknown, enabledRaw: unknown) => {
const id = validateId(idRaw)
if (!id || typeof enabledRaw !== 'boolean') return null
const c = updateChallenge(id, { enabled: enabledRaw })
broadcastState()
return c
}
)
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow())
// Dev helper: simulate a match end with given stats.
ipcMain.handle(
'dev:simulateMatchEnd',
(_e, id: GameId, stats: Record<string, number>) => {
simulateMatchEnd(id, stats)
}
)
// Dev helper: simulate a match end with given stats. NEVER registered in
// packaged builds — a compromised renderer (XSS, malicious npm dep) could
// otherwise fabricate arbitrary match-end events at will.
if (!app.isPackaged) {
ipcMain.handle(
'dev:simulateMatchEnd',
(_e, id: GameId, stats: Record<string, number>) => {
simulateMatchEnd(id, stats)
}
)
}
// Auto-updater
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus())
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
ipcMain.handle(IPC.updaterDownload, () => downloadUpdate())
ipcMain.handle(IPC.updaterInstall, () => quitAndInstall())
// History
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) =>
clearHistory(beforeTs)
)
}

View File

@@ -1,12 +1,20 @@
import { powerMonitor, BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc'
import type { Tick } from '@shared/types'
import { isQuietAt } from '@shared/types'
import { getExercises, getSettings, updateExercise } from './store'
import { fireReminder } from './notifications'
import { broadcastState } from './state-actions'
/**
* 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 CHECK_MS = 5000
let tickHandle: NodeJS.Timeout | null = null
let powerListenersArmed = false
let lastCheckAt = 0
let paused = false
@@ -15,18 +23,28 @@ function checkDueExercises(): void {
const settings = getSettings()
if (!settings.globalEnabled) return
// Inside the quiet window: defer all due fires until it closes. The next
// CHECK_MS pass after the window ends will pick them up.
if (isQuietAt(settings.quietHours, new Date())) return
const now = Date.now()
const exercises = getExercises()
let anyFired = false
for (const ex of exercises) {
if (!ex.enabled) continue
if (ex.nextFireAt <= now) {
// Fire once, reschedule from now (drop missed intervals).
const updated = updateExercise(ex.id, {
nextFireAt: now + ex.intervalMinutes * 60_000
})
if (updated) fireReminder(updated, settings.notificationMode)
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 {
@@ -57,14 +75,20 @@ export function startScheduler(): void {
// Run an immediate tick so renderer hydrates quickly.
tick()
powerMonitor.on('resume', () => {
lastCheckAt = 0
tick()
})
powerMonitor.on('unlock-screen', () => {
lastCheckAt = 0
tick()
})
// Only attach powerMonitor listeners once per process — startScheduler may
// be invoked again after stopScheduler in dev hot-reload paths and we don't
// want the same handler firing N times after a resume.
if (!powerListenersArmed) {
powerListenersArmed = true
powerMonitor.on('resume', () => {
lastCheckAt = 0
tick()
})
powerMonitor.on('unlock-screen', () => {
lastCheckAt = 0
tick()
})
}
}
export function stopScheduler(): void {

View File

@@ -1,5 +1,12 @@
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 { randomUUID } from 'node:crypto'
import {
@@ -8,10 +15,21 @@ import {
DEFAULT_SETTINGS,
Exercise,
GameId,
HistoryAction,
HistoryEntry,
SAMPLE_EXERCISES,
Settings
} from '@shared/types'
/**
* 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 WRITE_DEBOUNCE_MS = 1500
const WRITE_RETRY_DELAYS = [50, 200, 800] // ms backoff on transient EBUSY/EPERM
let cache: AppState | null = null
let storePath = ''
let pendingWrite: NodeJS.Timeout | null = null
@@ -56,7 +74,91 @@ function makeInitial(): AppState {
enabled: false
}
],
gamesEnabled: {}
gamesEnabled: {},
history: []
}
}
/** 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)
console.error(
`[store] app-state.json was unreadable (${reason}); ` +
`moved to ${dest} and starting fresh.`
)
} catch (e) {
console.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 AppState. */
function coerce(s: StoredState): AppState {
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[]) : []
}
}
@@ -64,26 +166,110 @@ function load(): AppState {
const p = getStorePath()
if (!existsSync(p)) {
const initial = makeInitial()
writeFileSync(p, JSON.stringify(initial, null, 2), 'utf-8')
atomicWrite(
p,
JSON.stringify(
{ __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial },
null,
2
)
)
return initial
}
let raw: string
try {
const raw = readFileSync(p, 'utf-8')
const parsed = JSON.parse(raw) as Partial<AppState>
return {
exercises: parsed.exercises ?? [],
settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) },
challenges: parsed.challenges ?? [],
gamesEnabled: parsed.gamesEnabled ?? {}
}
} catch {
raw = readFileSync(p, 'utf-8')
} catch (e) {
console.error('[store] cannot read state file:', e)
return makeInitial() // do not quarantine — we can't read it anyway
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (e) {
quarantineCorrupt(p, `JSON parse error: ${String(e)}`)
return makeInitial()
}
if (!isValidParsed(parsed)) {
quarantineCorrupt(p, `expected object, got ${typeof parsed}`)
return makeInitial()
}
return coerce(runMigrations(parsed))
}
function appendHistory(
exerciseId: string,
action: HistoryAction,
actualReps?: number
): void {
const state = getState()
if (!state.history) state.history = []
const entry: HistoryEntry = { ts: Date.now(), exerciseId, action }
if (actualReps !== undefined) entry.actualReps = actualReps
state.history.push(entry)
if (state.history.length > HISTORY_MAX) {
state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9))
}
// Caller schedules the write; appendHistory itself is internal.
}
export function getHistory(sinceMs?: number): HistoryEntry[] {
const all = getState().history ?? []
if (sinceMs == null) return all
return all.filter((e) => e.ts >= sinceMs)
}
export function clearHistory(beforeTs?: number): number {
const state = getState()
const before = state.history?.length ?? 0
if (beforeTs == null) {
// Refuse a full wipe via IPC — callers must pass an explicit boundary.
// (Settings UI passes 0 to wipe everything; that's an opt-in.)
return 0
}
state.history = (state.history ?? []).filter((e) => e.ts >= beforeTs)
scheduleWrite()
return before - (state.history?.length ?? 0)
}
/**
* Atomically write to `path` via a sibling .tmp file + rename. Retries a few
* times on transient EBUSY/EPERM (AV/OneDrive holding the file).
*/
function atomicWrite(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
// best-effort cleanup of the stale .tmp
try {
if (existsSync(tmp)) unlinkSync(tmp)
} catch {
/* ignore */
}
const delay = WRITE_RETRY_DELAYS[i]
if (delay === undefined) break
// Synchronous sleep — write path is short and called outside the hot loop.
const until = Date.now() + delay
while (Date.now() < until) {
/* spin */
}
}
}
console.error('[store] atomic write failed after retries:', lastErr)
}
function flush(): void {
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 }
atomicWrite(getStorePath(), JSON.stringify(payload, null, 2))
}
function scheduleWrite(): void {
@@ -91,7 +277,10 @@ function scheduleWrite(): void {
pendingWrite = setTimeout(() => {
pendingWrite = null
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 {
@@ -136,9 +325,15 @@ export function updateExercise(
const idx = state.exercises.findIndex((e) => e.id === id)
if (idx === -1) return undefined
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 (patch.intervalMinutes !== undefined && patch.intervalMinutes !== prev.intervalMinutes) {
if (
patch.intervalMinutes !== undefined &&
patch.intervalMinutes !== prev.intervalMinutes
) {
merged.nextFireAt = Date.now() + merged.intervalMinutes * 60_000
}
state.exercises[idx] = merged
@@ -155,12 +350,16 @@ export function deleteExercise(id: string): boolean {
return changed
}
export function markDone(id: string): Exercise | undefined {
export function markDone(
id: string,
actualReps?: number
): Exercise | undefined {
const state = getState()
const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined
ex.lastDoneAt = Date.now()
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
appendHistory(id, 'done', actualReps)
scheduleWrite()
return ex
}
@@ -170,6 +369,7 @@ export function snooze(id: string, minutes: number): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined
ex.nextFireAt = Date.now() + minutes * 60_000
appendHistory(id, 'snooze')
scheduleWrite()
return ex
}
@@ -179,6 +379,7 @@ export function skip(id: string): Exercise | undefined {
const ex = state.exercises.find((e) => e.id === id)
if (!ex) return undefined
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
appendHistory(id, 'skip')
scheduleWrite()
return ex
}
@@ -210,7 +411,9 @@ export function updateChallenge(
const state = getState()
const idx = state.challenges.findIndex((c) => c.id === id)
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()
return state.challenges[idx]
}

View File

@@ -3,9 +3,45 @@ import { join } from 'node:path'
import { showMainWindow } from './windows'
import { isPaused, setPaused, forceCheck } from './scheduler'
import { snoozeAll } from './state-actions'
import { getSettings } from './store'
import type { Language } from '@shared/types'
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 {
// Try resources/, fallback to a transparent 16x16 if missing during dev.
const candidates = [
@@ -35,10 +71,10 @@ export function refreshMenu(): void {
if (!tray) return
const paused = isPaused()
const menu = Menu.buildFromTemplate([
{ label: 'Открыть', click: () => showMainWindow() },
{ label: trayLabel('open'), click: () => showMainWindow() },
{ type: 'separator' },
{
label: paused ? 'Возобновить напоминания' : 'Пауза напоминаний',
label: paused ? trayLabel('resume') : trayLabel('pause'),
click: () => {
setPaused(!paused)
refreshMenu()
@@ -46,12 +82,12 @@ export function refreshMenu(): void {
}
},
{
label: 'Отложить все на 15 мин',
label: trayLabel('snooze15'),
click: () => snoozeAll(15)
},
{ type: 'separator' },
{
label: 'Выход',
label: trayLabel('quit'),
click: () => {
app.quit()
}

View File

@@ -4,16 +4,32 @@ import { IPC } from '@shared/ipc'
import type { UpdaterStatus } from '@shared/types'
let currentStatus: UpdaterStatus = { kind: 'idle' }
let lastCheckedAt: number | undefined
let wired = false
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 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 {
return currentStatus
}
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
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtUpdaterStatus, s)
@@ -38,9 +54,14 @@ export function initUpdater(): void {
autoUpdater.autoInstallOnAppQuit = true
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) => {
lastCheckedAt = Date.now()
setStatus({
kind: 'available',
version: info.version,
@@ -50,7 +71,12 @@ export function initUpdater(): void {
})
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) => {
@@ -68,23 +94,43 @@ export function initUpdater(): void {
})
autoUpdater.on('error', (err) => {
setStatus({
kind: 'error',
message: err instanceof Error ? err.message : String(err)
})
const message = err instanceof Error ? err.message : String(err)
if (silentMode) {
// Background check failed — keep previous status, don't show red banner.
// Will retry on the next hourly tick.
console.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(() => {
void checkForUpdates()
}, 5_000)
void bootCheckWithRetry()
}, BOOT_DELAY_MS)
// Periodic re-check
// Periodic re-check (silent).
checkInterval = setInterval(() => {
void checkForUpdates()
void checkForUpdates({ silent: true })
}, 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) return // exhausted retries
await new Promise((r) => setTimeout(r, delay))
}
}
export function stopUpdater(): void {
if (checkInterval) {
clearInterval(checkInterval)
@@ -92,15 +138,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
silentMode = opts.silent ?? false
try {
await autoUpdater.checkForUpdates()
} catch (err) {
setStatus({
kind: 'error',
message: err instanceof Error ? err.message : String(err)
})
const message = err instanceof Error ? err.message : String(err)
if (silentMode) {
console.warn('[updater] silent check failed (sync):', message)
} else {
setStatus({ kind: 'error', message })
}
} finally {
silentMode = false
}
return currentStatus
}

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

@@ -0,0 +1,311 @@
/**
* 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
} 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 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
if (
name === undefined ||
reps === undefined ||
intervalMinutes === undefined
) {
return null
}
return { name, reps, intervalMinutes, icon, enabled }
}
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
}
// 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 ('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

@@ -24,6 +24,48 @@ function windowIcon(): Electron.NativeImage | 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 {
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl) {
@@ -68,10 +110,7 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
if (showImmediately) win.show()
})
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
installSafeNavigation(win)
loadRoute(win, 'main')
mainWindow = win
@@ -123,6 +162,7 @@ export function createReminderWindow(): BrowserWindow {
})
win.setAlwaysOnTop(true, 'screen-saver')
installSafeNavigation(win)
loadRoute(win, 'reminder')
win.on('closed', () => {

View File

@@ -6,6 +6,7 @@ import type {
Exercise,
GameId,
GameStatus,
HistoryEntry,
MatchSummary,
Settings,
Tick,
@@ -16,7 +17,8 @@ type Unsub = () => void
type Handler<T> = (payload: T) => void
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)
return () => ipcRenderer.removeListener(channel, listener)
}
@@ -33,7 +35,8 @@ const api = {
ipcRenderer.invoke(IPC.deleteExercise, id),
toggleExercise: (id: string, enabled: boolean): Promise<Exercise> =>
ipcRenderer.invoke(IPC.toggleExercise, id, enabled),
markDone: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.markDone, id),
markDone: (id: string, actualReps?: number): Promise<Exercise> =>
ipcRenderer.invoke(IPC.markDone, id, actualReps),
snooze: (id: string, minutes: number): Promise<Exercise> =>
ipcRenderer.invoke(IPC.snooze, id, minutes),
skip: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.skip, id),
@@ -42,7 +45,8 @@ const api = {
ipcRenderer.invoke(IPC.updateSettings, patch),
getAccentColor: (): Promise<string> => ipcRenderer.invoke(IPC.getAccentColor),
getOsTheme: (): Promise<'light' | 'dark'> => ipcRenderer.invoke(IPC.getOsTheme),
getOsTheme: (): Promise<'light' | 'dark'> =>
ipcRenderer.invoke(IPC.getOsTheme),
pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll),
resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll),
@@ -67,17 +71,31 @@ const api = {
// Challenges
addChallenge: (input: Omit<Challenge, 'id'>): Promise<Challenge> =>
ipcRenderer.invoke(IPC.addChallenge, input),
updateChallenge: (id: string, patch: Partial<Challenge>): Promise<Challenge> =>
ipcRenderer.invoke(IPC.updateChallenge, id, patch),
updateChallenge: (
id: string,
patch: Partial<Challenge>
): Promise<Challenge> => ipcRenderer.invoke(IPC.updateChallenge, id, patch),
deleteChallenge: (id: string): Promise<boolean> =>
ipcRenderer.invoke(IPC.deleteChallenge, id),
toggleChallenge: (id: string, enabled: boolean): Promise<Challenge> =>
ipcRenderer.invoke(IPC.toggleChallenge, id, enabled),
closeMatchSummary: (): Promise<void> => ipcRenderer.invoke(IPC.closeMatchSummary),
closeMatchSummary: (): Promise<void> =>
ipcRenderer.invoke(IPC.closeMatchSummary),
simulateMatchEnd: (id: GameId, stats: Record<string, number>): Promise<void> =>
ipcRenderer.invoke('dev:simulateMatchEnd', id, stats),
// Dev-only: synthesize a match-end event from the renderer. The channel is
// 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('dev:simulateMatchEnd', id, stats)
}
: {}),
// Auto-updater
updaterStatus: (): Promise<UpdaterStatus> =>
@@ -87,13 +105,21 @@ const api = {
updaterDownload: (): Promise<void> => ipcRenderer.invoke(IPC.updaterDownload),
updaterInstall: (): Promise<void> => ipcRenderer.invoke(IPC.updaterInstall),
// History
getHistory: (sinceMs?: number): Promise<HistoryEntry[]> =>
ipcRenderer.invoke(IPC.getHistory, sinceMs),
clearHistory: (beforeTs?: number): Promise<number> =>
ipcRenderer.invoke(IPC.clearHistory, beforeTs),
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, 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),
onGamesChanged: (h: Handler<GameStatus[]>): Unsub => on(IPC.evtGamesChanged, h),
onGamesChanged: (h: Handler<GameStatus[]>): Unsub =>
on(IPC.evtGamesChanged, h),
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
on(IPC.evtUpdaterStatus, h)
}

View File

@@ -3,6 +3,7 @@ import { HashRouter, Route, Routes, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Sidebar } from './components/Sidebar'
import { Titlebar } from './components/Titlebar'
import { ErrorBoundary } from './components/ErrorBoundary'
import Dashboard from './pages/Dashboard'
import Exercises from './pages/Exercises'
import GamesPage from './pages/Games'
@@ -10,37 +11,48 @@ import ChallengesPage from './pages/Challenges'
import SettingsPage from './pages/Settings'
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 {
const hydrated = useAppStore((s) => s.hydrated)
const [mobileNavOpen, setMobileNavOpen] = useState(false)
useEffect(() => {
if (backendSubscribed) return undefined
backendSubscribed = true
const unsub = subscribeToBackend()
return unsub
return () => {
backendSubscribed = false
unsub()
}
}, [])
return (
<HashRouter>
<div className="h-screen w-screen flex flex-col bg-bg">
<Titlebar
title="Exercise Reminder"
onMenuClick={() => setMobileNavOpen(true)}
/>
<div className="flex-1 flex overflow-hidden">
<Sidebar
mobileOpen={mobileNavOpen}
onMobileClose={() => setMobileNavOpen(false)}
/>
<main className="flex-1 overflow-hidden min-w-0">
{hydrated ? (
<RoutedPages onNav={() => setMobileNavOpen(false)} />
) : (
<div className="p-8 text-text/45">Загрузка</div>
)}
</main>
<ErrorBoundary>
<HashRouter>
<div className="h-screen w-screen flex flex-col bg-bg">
<Titlebar onMenuClick={() => setMobileNavOpen(true)} />
<div className="flex-1 flex overflow-hidden">
<Sidebar
mobileOpen={mobileNavOpen}
onMobileClose={() => setMobileNavOpen(false)}
/>
<main className="flex-1 overflow-hidden min-w-0">
{hydrated ? (
<ErrorBoundary>
<RoutedPages onNav={() => setMobileNavOpen(false)} />
</ErrorBoundary>
) : (
// Neutral placeholder — settings (and lang) aren't loaded yet.
<div className="p-8 text-text/45" />
)}
</main>
</div>
</div>
</div>
</HashRouter>
</HashRouter>
</ErrorBoundary>
)
}

View File

@@ -1,14 +1,26 @@
import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion'
import { Check, Clock, X, Trophy, Frown, Gamepad2 } from 'lucide-react'
import {
Check,
Clock,
X,
Trophy,
Frown,
Gamepad2,
Minus,
Plus
} from 'lucide-react'
import type {
Exercise,
MatchSummary,
Settings,
ChallengeResult
ChallengeResult,
Language
} from '@shared/types'
import { statLabel } from '@shared/types'
import { Icon } from './lib/icon'
import { formatInterval } from './lib/format'
import { translate, translateN } from './i18n'
type Mode =
| { kind: 'idle' }
@@ -42,37 +54,34 @@ export default function ReminderApp(): JSX.Element {
}
}, [])
// Keyboard shortcuts (iOS-like Enter to confirm)
// ESC closes the match summary view too — keyboard parity with exercise mode.
useEffect(() => {
if (mode.kind !== 'exercise') return
const ex = mode.exercise
const snoozeMin = settings?.snoozeMinutes ?? 5
if (mode.kind !== 'match') return
function onKey(e: KeyboardEvent): void {
if (e.key === 'Enter') {
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)
}
if (e.key === 'Escape') close()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, settings?.snoozeMinutes])
}, [mode.kind])
function close(): void {
setMode({ kind: 'idle' })
window.api.reminderClose()
}
const lang: Language = settings?.language ?? 'ru'
if (mode.kind === 'idle') return <div className="reminder-shell" />
if (mode.kind === 'exercise') {
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
key={mode.exercise.id + ':' + mode.exercise.nextFireAt}
exercise={mode.exercise}
snoozeMinutes={settings?.snoozeMinutes ?? 5}
lang={lang}
onClose={close}
/>
)
@@ -81,12 +90,19 @@ export default function ReminderApp(): JSX.Element {
<MatchSummaryView
summary={mode.summary}
done={mode.done}
lang={lang}
onMarkDone={(id) =>
setMode({
kind: 'match',
summary: mode.summary,
done: new Set([...mode.done, id])
})
// Functional update so a second rapid click can't race against a stale
// `mode.done` captured in this closure.
setMode((m) =>
m.kind === 'match'
? {
kind: 'match',
summary: m.summary,
done: new Set([...m.done, id])
}
: m
)
}
onClose={close}
/>
@@ -96,14 +112,26 @@ export default function ReminderApp(): JSX.Element {
function ExerciseReminder({
exercise,
snoozeMinutes,
lang,
onClose
}: {
exercise: Exercise
snoozeMinutes: number
lang: Language
onClose: () => void
}): JSX.Element {
const t = (key: string, vars?: Record<string, string | number>): string =>
translate(lang, key, vars)
const [actualReps, setActualReps] = useState(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> {
await window.api.markDone(exercise.id)
// Only pass actualReps when user adjusted — otherwise leave undefined
// so history records the full planned value cleanly.
await window.api.markDone(exercise.id, adjusted ? actualReps : undefined)
onClose()
}
async function snooze(): Promise<void> {
@@ -114,6 +142,39 @@ function ExerciseReminder({
await window.api.skip(exercise.id)
onClose()
}
const dec = (): void => setActualReps((n) => Math.max(0, 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 (
<div className="reminder-shell flex flex-col h-full">
@@ -121,7 +182,7 @@ function ExerciseReminder({
<button
onClick={onClose}
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-destructive hover:text-white text-text/45 active:scale-90 transition-all"
aria-label="Закрыть"
aria-label={t('btn.close')}
>
<X size={13} strokeWidth={2.5} />
</button>
@@ -140,45 +201,79 @@ function ExerciseReminder({
</motion.div>
<div className="text-[13px] uppercase tracking-[0.18em] text-accent font-bold">
Время тренировки
{t('reminder.kicker')}
</div>
<h1 className="font-serif text-[30px] leading-tight tracking-tight mt-2 mb-3 font-bold">
{exercise.name}
</h1>
<div className="inline-flex items-baseline gap-2 font-mono-num">
<span className="text-[56px] font-semibold tracking-tight text-text leading-none">
{exercise.reps}
</span>
<span className="text-[15px] text-text/65 font-semibold">раз</span>
{/* Reps stepper — tap +/ if you did less than planned. */}
<div className="inline-flex items-center gap-3 select-none">
<button
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"
aria-label={t('reminder.aria.decrement')}
>
<Minus size={16} strokeWidth={2.5} />
</button>
<div className="inline-flex items-baseline gap-2 font-mono-num min-w-[120px] justify-center">
<span
className={[
'text-[56px] font-semibold tracking-tight leading-none',
adjusted ? 'text-accent' : 'text-text'
].join(' ')}
>
{actualReps}
</span>
<span className="text-[15px] text-text/65 font-semibold">
{t('reminder.reps')}
</span>
</div>
<button
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"
aria-label={t('reminder.aria.increment')}
>
<Plus size={16} strokeWidth={2.5} />
</button>
</div>
{adjusted && (
<div className="text-[12px] text-accent mt-2 font-medium">
{t('reminder.partial', {
actual: actualReps,
planned: exercise.reps
})}
</div>
)}
<div className="text-[13px] text-text/65 mt-4 inline-flex items-center gap-1.5 font-medium">
<Clock size={12} strokeWidth={2.4} />
Следующее через {formatInterval(exercise.intervalMinutes)}
{t('reminder.next_in', {
interval: formatInterval(exercise.intervalMinutes, lang)
})}
</div>
</div>
{/* iOS action sheet — buttons stacked vertically, equal width */}
<div className="px-4 pb-4 space-y-2">
<button
onClick={done}
className="w-full h-12 rounded-2xl bg-accent text-white text-[16px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
<Check size={17} strokeWidth={2.5} /> Готово
<Check size={17} strokeWidth={2.5} /> {t('reminder.btn.done')}
</button>
<div className="grid grid-cols-2 gap-2">
<button
onClick={snooze}
className="h-11 rounded-2xl bg-surface-2 text-text text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
<Clock size={15} strokeWidth={2.5} /> {snoozeMinutes} мин
<Clock size={15} strokeWidth={2.5} />{' '}
{t('btn.snooze_min', { n: snoozeMinutes })}
</button>
<button
onClick={skip}
className="h-11 rounded-2xl bg-surface-2 text-text/65 text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
>
Пропустить
{t('btn.skip')}
</button>
</div>
</div>
@@ -189,14 +284,24 @@ function ExerciseReminder({
function MatchSummaryView({
summary,
done,
lang,
onMarkDone,
onClose
}: {
summary: MatchSummary
done: Set<string>
lang: Language
onMarkDone: (id: string) => void
onClose: () => void
}): JSX.Element {
const t = (key: string, vars?: Record<string, string | number>): string =>
translate(lang, key, vars)
const tn = (
base: string,
n: number,
vars?: Record<string, string | number>
): string => translateN(lang, base, n, vars)
const allDone = summary.results.every((r) => done.has(r.challengeId))
const totalReps = summary.results.reduce((s, r) => s + r.reps, 0)
const remainingReps = summary.results
@@ -204,6 +309,7 @@ function MatchSummaryView({
.reduce((s, r) => s + r.reps, 0)
const won = summary.won === true
const lost = summary.won === false
const minutes = Math.floor(summary.durationMs / 60_000)
return (
<div className="reminder-shell flex flex-col h-full">
@@ -214,7 +320,7 @@ function MatchSummaryView({
<button
onClick={onClose}
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-destructive hover:text-white text-text/45 active:scale-90 transition-all"
aria-label="Закрыть"
aria-label={t('btn.close')}
>
<X size={13} strokeWidth={2.5} />
</button>
@@ -239,19 +345,24 @@ function MatchSummaryView({
)}
</motion.div>
<h1 className="font-serif text-[26px] tracking-tight font-bold">
{won ? 'Победа' : lost ? 'Поражение' : 'Матч завершён'}
{won
? t('match.title.won')
: lost
? t('match.title.lost')
: t('match.title.draw')}
</h1>
<p className="text-[13px] text-text/65 mt-1.5 font-medium">
<span className="font-mono-num font-bold text-text">
{Math.floor(summary.durationMs / 60_000)}
</span>{' '}
мин · {summary.results.length} челлендж
{summary.results.length === 1 ? '' : 'а'} ·{' '}
<span className="font-mono-num font-bold text-text">{minutes}</span>{' '}
{t('fmt.m')} ·{' '}
{tn('match.summary.challenges', summary.results.length)}
{' · '}
{allDone ? (
<span className="text-success font-bold">всё готово</span>
<span className="text-success font-bold">
{t('match.summary.all_done')}
</span>
) : (
<span className="text-accent font-mono-num font-bold">
{remainingReps} осталось
{t('match.summary.remaining', { n: remainingReps })}
</span>
)}
</p>
@@ -262,6 +373,7 @@ function MatchSummaryView({
<ChallengeRow
key={r.challengeId}
result={r}
lang={lang}
done={done.has(r.challengeId)}
onMarkDone={() => onMarkDone(r.challengeId)}
/>
@@ -270,11 +382,11 @@ function MatchSummaryView({
<div className="px-4 pb-4 pt-3 flex items-center gap-3">
<div className="flex-1 text-[13px] text-text/65 font-medium">
Всего ·{' '}
{t('match.total')} ·{' '}
<span className="text-text font-mono-num font-bold text-[16px]">
{totalReps}
</span>{' '}
повторов
{t('match.total_reps_suffix')}
</div>
<button
onClick={onClose}
@@ -285,10 +397,10 @@ function MatchSummaryView({
>
{allDone ? (
<>
<Check size={14} strokeWidth={2.5} /> Закрыть
<Check size={14} strokeWidth={2.5} /> {t('btn.close')}
</>
) : (
'Позже'
t('btn.later')
)}
</button>
</div>
@@ -298,13 +410,16 @@ function MatchSummaryView({
function ChallengeRow({
result,
lang,
done,
onMarkDone
}: {
result: ChallengeResult
lang: Language
done: boolean
onMarkDone: () => void
}): JSX.Element {
const label = result.stat ? statLabel(result.stat, lang) : result.statLabel
return (
<motion.div
layout
@@ -336,7 +451,7 @@ function ChallengeRow({
<span className="font-mono-num font-bold text-text">
{result.statValue}
</span>{' '}
{result.statLabel} <span>{result.name}</span>
{label} <span>{result.name}</span>
</div>
</div>
<div
@@ -356,7 +471,6 @@ function ChallengeRow({
? 'bg-success text-white cursor-default'
: 'bg-accent text-white active:scale-90'
].join(' ')}
aria-label="Готово"
>
<Check size={15} strokeWidth={2.5} />
</button>

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

@@ -3,8 +3,9 @@ import { Check, MoreHorizontal } from 'lucide-react'
import { useState } from 'react'
import type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon'
import { formatCountdown, formatInterval } from '../lib/format'
import { formatCountdown } from '../lib/format'
import { Switch } from './ui/Switch'
import { useT } from '../i18n'
type Props = {
exercise: Exercise
@@ -34,6 +35,7 @@ export function ExerciseCard({
const elapsedPct = total > 0 ? 1 - remaining / total : 0
const isDue = ms <= 0 && exercise.enabled
const [menuOpen, setMenuOpen] = useState(false)
const { t, lang } = useT()
// Ring math
const R = 22
@@ -76,9 +78,7 @@ export function ExerciseCard({
strokeLinecap="round"
strokeDasharray={C}
strokeDashoffset={dashOffset}
className={
isDue ? 'stroke-accent' : 'stroke-accent/85'
}
className={isDue ? 'stroke-accent' : 'stroke-accent/85'}
style={{ transition: 'stroke-dashoffset 0.5s linear' }}
/>
)}
@@ -104,7 +104,7 @@ export function ExerciseCard({
<button
onClick={() => setMenuOpen((v) => !v)}
className="w-7 h-7 grid place-items-center rounded-full text-text/45 hover:bg-surface-2 active:scale-90 transition-all"
aria-label="Меню"
aria-label={t('titlebar.menu_aria')}
>
<MoreHorizontal size={16} />
</button>
@@ -122,7 +122,7 @@ export function ExerciseCard({
}}
className="w-full text-left px-3 py-2 text-[13px] hover:bg-surface-2 active:bg-hairline/25"
>
Редактировать
{t('btn.edit')}
</button>
<button
onClick={() => {
@@ -131,7 +131,7 @@ export function ExerciseCard({
}}
className="w-full text-left px-3 py-2 text-[13px] text-destructive hover:bg-destructive/10 active:bg-destructive/15"
>
Удалить
{t('btn.delete')}
</button>
</div>
</>
@@ -139,14 +139,17 @@ export function ExerciseCard({
</div>
</div>
<div className="text-[14px] text-text/65 mt-1 font-medium">
{exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)}
{t('editor.exercise.preview.meta', {
reps: exercise.reps,
min: exercise.intervalMinutes
})}
</div>
{/* Countdown + switch */}
<div className="flex items-end justify-between mt-3.5">
<div>
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold">
{isDue ? 'Сейчас' : 'Через'}
{isDue ? t('dashboard.stat.next.now') : t('fmt.through')}
</div>
<div
className={[
@@ -154,19 +157,18 @@ export function ExerciseCard({
isDue ? 'text-accent' : 'text-text'
].join(' ')}
>
{exercise.enabled ? formatCountdown(ms) : 'на паузе'}
{exercise.enabled ? formatCountdown(ms, lang) : t('fmt.paused')}
</div>
</div>
<Switch
checked={exercise.enabled}
onChange={onToggle}
aria-label="Включить/выключить"
aria-label={t('exercise.aria.toggle', { name: exercise.name })}
/>
</div>
</div>
</div>
{/* Done action — appears as filled pill at bottom only on due */}
{isDue && (
<motion.button
initial={{ opacity: 0, y: 4 }}
@@ -174,7 +176,7 @@ export function ExerciseCard({
onClick={onMarkDone}
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} /> Готово
<Check size={15} strokeWidth={2.5} /> {t('btn.done')}
</motion.button>
)}
</motion.div>

View File

@@ -3,6 +3,7 @@ import type { Exercise } from '@shared/types'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { ICON_CHOICES, Icon } from '../lib/icon'
import { useT } from '../i18n'
type Draft = {
name: string
@@ -34,6 +35,7 @@ export function ExerciseEditor({
onSave
}: Props): JSX.Element {
const [draft, setDraft] = useState<Draft>(EMPTY)
const { t } = useT()
useEffect(() => {
if (exercise) {
@@ -55,46 +57,52 @@ export function ExerciseEditor({
<Modal
open={open}
onClose={onClose}
title={exercise ? 'Редактировать' : 'Новое упражнение'}
title={
exercise
? t('editor.exercise.title.edit')
: t('editor.exercise.title.new')
}
footer={
<>
<Button variant="plain" onClick={onClose}>
Отмена
{t('btn.cancel')}
</Button>
<Button disabled={!canSave} onClick={() => onSave(draft)}>
Сохранить
{t('btn.save')}
</Button>
</>
}
>
<div className="space-y-5">
{/* Live preview header */}
<div className="rounded-2xl bg-surface-2 p-4 flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-accent text-white grid place-items-center shrink-0">
<Icon name={draft.icon} size={26} strokeWidth={2.2} />
</div>
<div className="min-w-0">
<div className="font-display text-[18px] font-semibold tracking-tight truncate">
{draft.name || 'Без названия'}
{draft.name || t('editor.exercise.preview.placeholder')}
</div>
<div className="text-[13px] text-text/55 mt-0.5 font-mono-num">
{draft.reps} раз · каждые {draft.intervalMinutes} мин
{t('editor.exercise.preview.meta', {
reps: draft.reps,
min: draft.intervalMinutes
})}
</div>
</div>
</div>
<Field label="Название">
<Field label={t('editor.field.name')}>
<input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="Приседания"
placeholder={t('editor.field.name.placeholder')}
className="ios-input"
autoFocus
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Повторений">
<Field label={t('editor.field.reps')}>
<input
type="number"
min={1}
@@ -108,7 +116,7 @@ export function ExerciseEditor({
className="ios-input font-mono-num"
/>
</Field>
<Field label="Интервал (мин)">
<Field label={t('editor.field.interval_min')}>
<input
type="number"
min={1}
@@ -124,7 +132,7 @@ export function ExerciseEditor({
</Field>
</div>
<Field label="Иконка">
<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">
{ICON_CHOICES.map((name) => (
<button

View File

@@ -0,0 +1,201 @@
import { useMemo } from 'react'
import { dailyRepsRange } from '../lib/history'
import type { Exercise, HistoryEntry } from '@shared/types'
import { translateN, useT } from '../i18n'
type Props = {
history: HistoryEntry[]
exercises: Exercise[]
days?: number
}
/**
* GitHub-style contribution grid: weeks as columns, days-of-week as rows.
*
* 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({
history,
exercises,
days = 84 // 12 weeks
}: Props): JSX.Element {
const { t, lang } = useT()
const cells = useMemo(
() => dailyRepsRange(history, exercises, days),
[history, exercises, days]
)
// 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 {
if (n === 0 || !thresholds) return 0
if (n <= thresholds.p25) return 1
if (n <= thresholds.p50) return 2
if (n <= thresholds.p85) return 3
return 4
}
// Group cells into columns (weeks). Pad start so the first column aligns
// to its actual weekday (Mon-first).
const weeks = useMemo(() => {
const firstDay = cells[0]?.date ?? new Date()
const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon
const padded: ({
key: string
date: Date
reps: number
} | null)[] = [...Array(firstWeekday).fill(null), ...cells]
const out: (typeof padded)[] = []
for (let i = 0; i < padded.length; i += 7) {
out.push(padded.slice(i, i + 7))
}
return out
}, [cells])
// Day labels along the Y axis. Mon-first, only label every other day to
// keep the column narrow. Pulled from the i18n dict (index = Date.getDay()).
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 fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', {
month: 'short'
})
return weeks.map((w) => {
const first = w.find((c) => c !== null)
return first ? fmt.format(first.date) : ''
})
}, [weeks, lang])
// Show a month label only on the first week that lands inside it.
const monthLabelsCompressed = monthLabels.map((label, i) =>
label && label !== monthLabels[i - 1] ? label : ''
)
const dateFmt = useMemo(
() =>
new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', {
day: 'numeric',
month: 'long'
}),
[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 (
<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="text-[14px] text-text/75 font-semibold">
{t('heatmap.title')}
</div>
</div>
<div className="overflow-x-auto">
{/* Month labels above grid */}
<div className="flex gap-[3px] mb-1 pl-7">
{monthLabelsCompressed.map((label, i) => (
<div
key={i}
className="w-[12px] text-[10px] text-text/45 font-medium"
>
{label}
</div>
))}
</div>
<div className="flex gap-[6px]">
<div className="flex flex-col gap-[3px] justify-around pt-0.5">
{dayLabels.map((l, i) => (
<div
key={i}
className="h-[12px] text-[10px] text-text/40 font-medium leading-none w-5 text-right"
>
{l}
</div>
))}
</div>
<div className="flex gap-[3px]">
{weeks.map((w, wi) => (
<div key={wi} className="flex flex-col gap-[3px]">
{w.map((c, di) => {
if (!c) {
return <div key={di} className="w-[12px] h-[12px]" />
}
const b = bucket(c.reps)
const tone =
b === 0
? 'bg-surface-2'
: b === 1
? 'bg-accent/30'
: b === 2
? 'bg-accent/55'
: b === 3
? 'bg-accent/80'
: 'bg-accent'
return (
<div
key={di}
title={`${dateFmt.format(c.date)} · ${repsLabel(c.reps)}`}
className={[
'w-[12px] h-[12px] rounded-[3px] transition-colors',
tone
].join(' ')}
/>
)
})}
</div>
))}
</div>
</div>
</div>
{/* Legend */}
<div className="flex items-center justify-end gap-1.5 mt-3 text-[10px] text-text/45 font-medium">
<span>{t('heatmap.legend.less')}</span>
{[0, 1, 2, 3, 4].map((b) => (
<div
key={b}
className={[
'w-[10px] h-[10px] rounded-[2px]',
b === 0
? 'bg-surface-2'
: b === 1
? 'bg-accent/30'
: b === 2
? 'bg-accent/55'
: b === 3
? 'bg-accent/80'
: 'bg-accent'
].join(' ')}
/>
))}
<span>{t('heatmap.legend.more')}</span>
</div>
</div>
)
}

View File

@@ -1,36 +1,35 @@
import { useEffect, useRef } from 'react'
import { NavLink } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import {
Sun,
Dumbbell,
Joystick,
Flame,
Settings2,
X
} from 'lucide-react'
import { Sun, Dumbbell, Joystick, Flame, Settings2, X } from 'lucide-react'
import { useT } from '../i18n'
type Item = {
to: string
label: string
labelKey: string
icon: typeof Sun
end?: boolean
tint?: string
}
// Tinted icon plaques á la iOS Settings rows.
const items: Item[] = [
{ to: '/', label: 'Сегодня', icon: Sun, end: true, tint: 'bg-accent' },
{ to: '/', labelKey: 'nav.today', icon: Sun, end: true, tint: 'bg-accent' },
{
to: '/exercises',
label: 'Упражнения',
labelKey: 'nav.exercises',
icon: Dumbbell,
tint: 'bg-info'
},
{ to: '/games', label: 'Игры', icon: Joystick, tint: 'bg-accent-2' },
{ to: '/challenges', label: 'Челленджи', icon: Flame, tint: 'bg-warning' },
{ to: '/games', labelKey: 'nav.games', icon: Joystick, tint: 'bg-accent-2' },
{
to: '/challenges',
labelKey: 'nav.challenges',
icon: Flame,
tint: 'bg-warning'
},
{
to: '/settings',
label: 'Настройки',
labelKey: 'nav.settings',
icon: Settings2,
tint: 'bg-text/70'
}
@@ -45,14 +44,60 @@ export function Sidebar({
mobileOpen = false,
onMobileClose
}: Props): JSX.Element {
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 (
<>
{/* Desktop sidebar — macOS vibrancy panel */}
<aside className="hidden md:flex w-64 shrink-0 vibrancy hairline-b border-r-0 flex-col">
<SidebarContent />
</aside>
{/* Mobile drawer */}
<AnimatePresence>
{mobileOpen && (
<motion.div
@@ -68,8 +113,13 @@ export function Sidebar({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
aria-hidden="true"
/>
<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"
initial={{ x: '-100%' }}
animate={{ x: 0 }}
@@ -79,7 +129,7 @@ export function Sidebar({
<button
onClick={onMobileClose}
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 transition-colors active:scale-90"
aria-label="Закрыть"
aria-label={t('btn.close')}
>
<X size={14} strokeWidth={2.5} />
</button>
@@ -93,21 +143,20 @@ export function Sidebar({
}
function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
const { t } = useT()
return (
<>
{/* Brand */}
<div className="px-5 pt-7 pb-6">
<div className="font-serif text-[36px] leading-none tracking-tight font-bold">
Laude
</div>
<div className="text-[13px] text-text/55 mt-2 font-medium">
Двигайся осознанно
{t('sidebar.slogan')}
</div>
</div>
{/* Nav */}
<nav className="px-2.5 flex flex-col gap-1">
{items.map(({ to, label, icon: Icon, end, tint }) => (
{items.map(({ to, labelKey, icon: Icon, end, tint }) => (
<NavLink
key={to}
to={to}
@@ -140,7 +189,7 @@ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
: 'text-text/85 font-medium'
].join(' ')}
>
{label}
{t(labelKey)}
</span>
</>
)}
@@ -148,14 +197,13 @@ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
))}
</nav>
{/* Status footer */}
<div className="mt-auto px-5 pb-5">
<div className="flex items-center gap-2 text-[11px] text-text/45">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
</span>
Активность отслеживается
{t('sidebar.status_tracking')}
</div>
</div>
</>

View File

@@ -1,46 +1,49 @@
import { Minus, X, Square, Menu } from 'lucide-react'
import { useT } from '../i18n'
type Props = {
title: string
title?: string
onMenuClick?: () => void
}
/**
* macOS-style translucent titlebar. Title centred small, no app icon.
* Window buttons sit right; a left-side hamburger surfaces on mobile only.
*/
export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
const { t } = useT()
const effectiveTitle = title ?? t('titlebar.app_title')
return (
<div className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b">
{/* Left: hamburger only on small */}
<div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
{onMenuClick && (
<button
onClick={onMenuClick}
className="titlebar-nodrag md:hidden w-8 h-7 grid place-items-center rounded-md hover:bg-text/[0.08] text-text/65 transition-colors"
aria-label="Меню"
className="titlebar-nodrag md:hidden w-8 h-7 grid place-items-center rounded-md hover:bg-text/[0.08] text-text/65 hover:text-text transition-colors"
aria-label={t('titlebar.menu_aria')}
>
<Menu size={15} strokeWidth={2} />
</button>
)}
</div>
{/* Centre title */}
<div className="text-[12px] font-medium text-text/55 truncate px-2">
{title}
{effectiveTitle}
</div>
{/* Right window controls */}
<div className="titlebar-nodrag flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
<WinBtn onClick={() => window.api.minimizeMain()} label="Свернуть">
<WinBtn
onClick={() => window.api.minimizeMain()}
label={t('titlebar.minimize_aria')}
>
<Minus size={13} strokeWidth={2} />
</WinBtn>
<WinBtn onClick={() => window.api.hideMain()} label="В трей">
<WinBtn
onClick={() => window.api.hideMain()}
label={t('titlebar.tray_aria')}
>
<Square size={11} strokeWidth={2} />
</WinBtn>
<WinBtn
onClick={() => window.api.closeMain()}
label="Закрыть"
label={t('titlebar.close_aria')}
danger
>
<X size={13} strokeWidth={2} />

View File

@@ -10,8 +10,18 @@ import {
import { motion } from 'framer-motion'
import { Button } from './ui/Button'
import { Card } from './ui/Card'
import { useT, type TFn } from '../i18n'
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 {
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
const [busy, setBusy] = useState(false)
@@ -67,13 +77,15 @@ function Body({
onDownload: () => void
onInstall: () => void
}): JSX.Element {
const { t } = useT()
if (status.kind === 'unsupported') {
return (
<Cell
tone="muted"
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
title="Auto-update недоступен"
subtitle={status.reason}
title={t('updater.unsupported')}
subtitle={t('updater.unsupported.reason_dev')}
/>
)
}
@@ -81,21 +93,29 @@ function Body({
return (
<Cell
tone="info"
icon={<RefreshCw size={16} strokeWidth={2.4} className="animate-spin" />}
title="Проверяем обновления…"
icon={
<RefreshCw size={16} strokeWidth={2.4} className="animate-spin" />
}
title={t('updater.checking')}
/>
)
}
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 (
<Cell
tone="success"
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
title="Последняя версия"
subtitle={`Текущая: v${status.currentVersion}`}
title={t('updater.up_to_date')}
subtitle={subtitle}
action={
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={13} strokeWidth={2.5} /> Проверить
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')}
</Button>
}
/>
@@ -106,23 +126,31 @@ function Body({
<Cell
tone="accent"
icon={<Sparkles size={16} strokeWidth={2.4} />}
title={`Доступна v${status.version}`}
title={t('updater.available.title', { v: status.version })}
subtitle={
status.releaseDate
? new Date(status.releaseDate).toLocaleString('ru-RU')
? new Date(status.releaseDate).toLocaleString()
: undefined
}
action={
<Button size="sm" onClick={onDownload} disabled={busy}>
<Download size={13} strokeWidth={2.5} /> Скачать
<Download size={13} strokeWidth={2.5} /> {t('btn.download')}
</Button>
}
/>
)
}
if (status.kind === 'downloading') {
const pct = Math.max(0, Math.min(100, status.percent || 0))
const mb = (n: number): string => (n / 1024 / 1024).toFixed(1)
// electron-updater fires early `download-progress` events where some
// 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 (
<div className="px-4 py-4">
<div className="flex items-center gap-3 mb-3">
@@ -131,11 +159,14 @@ function Body({
</div>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
Загружаем обновление
{t('updater.downloading.title')}
</div>
<div className="text-[13px] text-text/65 mt-1 font-mono-num font-medium">
{mb(status.transferred)} / {mb(status.total)} МБ ·{' '}
{(status.bytesPerSecond / 1024 / 1024).toFixed(2)} МБ/с
{t('updater.downloading.subtitle', {
got: mb(status.transferred),
total: mb(status.total),
speed
})}
</div>
</div>
<div className="font-mono-num font-bold text-[18px] text-accent">
@@ -157,11 +188,11 @@ function Body({
<Cell
tone="success"
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
title={`Готово · v${status.version}`}
subtitle="Перезапусти для применения"
title={t('updater.downloaded.title', { v: status.version })}
subtitle={t('updater.downloaded.subtitle')}
action={
<Button variant="filled" size="sm" onClick={onInstall}>
Перезапустить
{t('btn.restart')}
</Button>
}
/>
@@ -172,11 +203,11 @@ function Body({
<Cell
tone="destructive"
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
title="Ошибка проверки"
title={t('updater.error.title')}
subtitle={status.message}
action={
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={13} strokeWidth={2.5} /> Повторить
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.retry')}
</Button>
}
/>
@@ -186,11 +217,11 @@ function Body({
<Cell
tone="muted"
icon={<PackageCheck size={16} strokeWidth={2.4} />}
title="Проверить обновления"
subtitle="Авто-проверка раз в час"
title={t('updater.idle.title')}
subtitle={t('updater.idle.subtitle')}
action={
<Button size="sm" onClick={onCheck} disabled={busy}>
<RefreshCw size={13} strokeWidth={2.5} /> Проверить
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')}
</Button>
}
/>

View File

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

View File

@@ -1,6 +1,7 @@
import { AnimatePresence, motion } from 'framer-motion'
import { X } from 'lucide-react'
import { ReactNode, useEffect } from 'react'
import { ReactNode, useEffect, useId, useRef } from 'react'
import { useT } from '../../i18n'
type Props = {
open: boolean
@@ -17,9 +18,25 @@ const sizeClass = {
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.
* 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({
open,
@@ -29,6 +46,12 @@ export function Modal({
footer,
size = 'md'
}: Props): JSX.Element {
const { t } = useT()
const titleId = useId()
const sheetRef = useRef<HTMLDivElement | null>(null)
const lastFocusedRef = useRef<HTMLElement | null>(null)
// Esc closes.
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent): void => {
@@ -38,6 +61,60 @@ export function Modal({
return () => window.removeEventListener('keydown', onKey)
}, [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 (
<AnimatePresence>
{open && (
@@ -50,8 +127,10 @@ export function Modal({
onClick={onClose}
>
<motion.div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={[
'relative w-full bg-surface rounded-3xl shadow-sheet flex flex-col overflow-hidden',
sizeClass[size]
@@ -64,13 +143,17 @@ export function Modal({
>
{/* Header — iOS large modal title */}
<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}
</h2>
<button
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"
aria-label="Закрыть"
aria-label={t('btn.close')}
>
<X size={14} strokeWidth={2.5} />
</button>

View File

@@ -0,0 +1,495 @@
/**
* Flat string dictionary for ru/en. Keys use dot notation but are just
* strings — no nesting overhead.
*
* Interpolation: `{name}` placeholders are replaced via `useT()` helper.
*
* Pluralization: keys ending in `.one`/`.few`/`.many` (ru) or
* `.one`/`.other` (en) are picked by `tn()` helper based on count.
*/
export type Dict = Record<string, string>
export const ru: Dict = {
// Sidebar / nav
'nav.today': 'Сегодня',
'nav.exercises': 'Упражнения',
'nav.games': 'Игры',
'nav.challenges': 'Челленджи',
'nav.settings': 'Настройки',
'sidebar.slogan': 'Двигайся осознанно',
'sidebar.status_tracking': 'Активность отслеживается',
'titlebar.menu_aria': 'Меню',
'titlebar.minimize_aria': 'Свернуть',
'titlebar.tray_aria': 'В трей',
'titlebar.close_aria': 'Закрыть',
'titlebar.app_title': 'Exercise Reminder',
// Common buttons / actions
'btn.add': 'Добавить',
'btn.new': 'Новый',
'btn.cancel': 'Отмена',
'btn.save': 'Сохранить',
'btn.done': 'Готово',
'btn.start': 'Старт',
'btn.pause': 'Пауза',
'btn.refresh': 'Обновить',
'btn.edit': 'Редактировать',
'btn.delete': 'Удалить',
'btn.snooze_min': 'Отложить {n} мин',
'btn.skip': 'Пропустить',
'btn.close': 'Закрыть',
'btn.later': 'Позже',
'btn.connect': 'Подключить',
'btn.disconnect': 'Отключить',
'btn.check': 'Проверить',
'btn.download': 'Скачать',
'btn.restart': 'Перезапустить',
'btn.retry': 'Повторить',
// Dashboard
'dashboard.kicker': 'Тренировка дня',
'dashboard.title': 'Сегодня',
'dashboard.stat.active': 'Активных',
'dashboard.stat.active.of': 'из {total}',
'dashboard.stat.today_done': 'Сегодня',
'dashboard.stat.today_done.subtitle': 'повторов за день',
'dashboard.stat.streak': 'Стрик',
'dashboard.stat.streak.subtitle': '{n} дн. подряд',
'dashboard.stat.next': 'До следующего',
'dashboard.stat.next.now': 'Сейчас',
'dashboard.stat.next.subtitle_paused': 'на паузе',
'dashboard.stat.next.subtitle_running': 'отсчёт идёт',
'dashboard.stat.tracking': 'Трекинг матчей',
'dashboard.stat.tracking.on': 'On',
'dashboard.stat.tracking.off': 'Off',
'dashboard.stat.tracking.subtitle_on': 'в реальном времени',
'dashboard.stat.tracking.subtitle_off': 'выключен',
'dashboard.paused.title': 'Напоминания на паузе',
'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт',
'dashboard.empty.title': 'Программа пуста',
'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать',
// Exercises
'exercises.kicker': 'Программа',
'exercises.title': 'Упражнения',
'exercises.section.active': 'Активные · {n}',
'exercises.section.disabled': 'Выключенные · {n}',
'exercises.row.meta': '{reps} раз · {interval}',
'exercises.empty': 'Программа пуста — добавь первое упражнение',
// Exercise editor
'editor.exercise.title.new': 'Новое упражнение',
'editor.exercise.title.edit': 'Редактировать',
'editor.exercise.preview.placeholder': 'Без названия',
'editor.exercise.preview.meta': '{reps} раз · каждые {min} мин',
'editor.field.name': 'Название',
'editor.field.name.placeholder': 'Приседания',
'editor.field.reps': 'Повторений',
'editor.field.interval_min': 'Интервал (мин)',
'editor.field.icon': 'Иконка',
// Challenges
'challenges.kicker': 'Правила за матч',
'challenges.title': 'Челленджи',
'challenges.subtitle': 'Повторов = {formula}',
'challenges.subtitle.formula': 'статистика × коэффициент',
'challenges.warning.no_games':
'Челленджи срабатывают после матча. Подключи игру во вкладке «Игры».',
'challenges.section.all': 'Все · {n}',
'challenges.empty':
'Челленджей пока нет. Привяжи упражнение к статистике матча.',
// Challenge editor
'editor.challenge.title.new': 'Новый челлендж',
'editor.challenge.title.edit': 'Редактировать',
'editor.field.challenge_name': 'Название',
'editor.field.challenge_name.placeholder': 'За смерти — приседания',
'editor.field.game': 'Игра',
'editor.field.stat': 'Статистика',
'editor.field.multiplier': 'Коэффициент',
'editor.field.exercise_name': 'Упражнение',
'editor.field.exercise_name.placeholder': 'Приседания',
'editor.challenge.preview.kicker': 'Превью · 5 событий',
'editor.challenge.preview.fallback': 'повторов',
// Games
'games.kicker': 'Трекинг матчей',
'games.title': 'Игры',
'games.subtitle': 'Подключи игру — челленджи сработают сразу после матча',
'games.subtitle.live': '{n} live',
'games.section.supported': 'Поддерживаемые',
'games.scanning': 'Сканируем установленные игры…',
'games.queued.body':
'Steam запущен. Параметр {opt} пропишется автоматически при следующем закрытии Steam.',
'games.no_user.body':
'В Steam нет залогиненного аккаунта (нет папки userdata). Запусти Steam один раз и нажми «Установить интеграцию».',
'games.not_installed.hint': 'Установи игру в Steam и нажми «Обновить»',
'games.dev.toggle': 'dev · симулировать конец матча',
'games.badge.live': 'Live',
'games.badge.ready': 'Готово',
'games.badge.queued': 'В очереди',
'games.badge.installed': 'Установлена',
'games.badge.not_found': 'Не найдена',
// Settings
'settings.kicker': 'Конфигурация',
'settings.title': 'Настройки',
'settings.section.reminders': 'Напоминания',
'settings.section.quiet': 'Тихие часы',
'settings.section.window': 'Окно и трей',
'settings.section.appearance': 'Внешний вид',
'settings.section.language': 'Язык',
'settings.section.updates': 'Обновления',
'settings.notification_mode.label': 'Режим уведомления',
'settings.notification_mode.hint': 'Как должно выглядеть напоминание',
'settings.notification_mode.modal': 'Окно поверх всех',
'settings.notification_mode.toast': 'Системное уведомление',
'settings.notification_mode.both': 'Окно и уведомление',
'settings.sound.label': 'Звук уведомления',
'settings.sound.hint': 'Короткий сигнал при срабатывании',
'settings.snooze.label': '«Отложить» на',
'settings.snooze.hint': 'Сколько минут добавлять при отложении',
'settings.snooze.1': '1 минута',
'settings.snooze.5': '5 минут',
'settings.snooze.10': '10 минут',
'settings.snooze.15': '15 минут',
'settings.snooze.30': '30 минут',
'settings.quiet.enabled.label': 'Тихие часы',
'settings.quiet.enabled.hint': 'Не показывать напоминания в указанные часы',
'settings.quiet.times.label': 'С и до',
'settings.quiet.times.hint': 'Если до раньше — окно переходит через полночь',
'settings.quiet.days.label': 'Дни недели',
'settings.quiet.days.hint': 'Тихие часы действуют в выбранные дни',
'settings.tray.label': 'Сворачивать в трей',
'settings.tray.hint': 'При закрытии остаётся работать в фоне',
'settings.autostart.label': 'Запускать с Windows',
'settings.autostart.hint': 'Открывать при входе в систему',
'settings.start_minimized.label': 'Запускать свёрнутым',
'settings.start_minimized.hint': 'При автозапуске открывать сразу в трее',
'settings.theme.label': 'Тема',
'settings.theme.hint': 'Светлая / тёмная / как в системе',
'settings.theme.system': 'Как в системе',
'settings.theme.light': 'Светлая',
'settings.theme.dark': 'Тёмная',
'settings.language.label': 'Язык интерфейса',
'settings.language.hint': 'Применяется сразу',
'settings.language.ru': 'Русский',
'settings.language.en': 'English',
'settings.loading': 'Загрузка…',
// Updater
'updater.unsupported': 'Auto-update недоступен',
'updater.unsupported.reason_dev': 'Auto-update недоступен в dev-режиме',
'updater.checking': 'Проверяем обновления…',
'updater.up_to_date': 'Последняя версия',
'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.downloading.title': 'Загружаем обновление',
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
'updater.downloaded.title': 'Готово · v{v}',
'updater.downloaded.subtitle': 'Перезапусти для применения',
'updater.error.title': 'Ошибка проверки',
'updater.idle.title': 'Проверить обновления',
'updater.idle.subtitle': 'Авто-проверка раз в час',
// Reminder window
'reminder.kicker': 'Время тренировки',
'reminder.subkicker': 'Двигайся',
'reminder.reps': 'раз',
'reminder.next_in': 'Следующее через {interval}',
'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': 'Готово',
'match.title.won': 'Победа',
'match.title.lost': 'Поражение',
'match.title.draw': 'Матч завершён',
'match.summary.minutes_count': '{n} мин',
'match.summary.challenges_one': '{n} челлендж',
'match.summary.challenges_few': '{n} челленджа',
'match.summary.challenges_many': '{n} челленджей',
'match.summary.all_done': 'всё готово',
'match.summary.remaining': '{n} осталось',
'match.total': 'Всего',
'match.total_reps_suffix': 'повторов',
// Format helpers
'fmt.now': 'сейчас',
'fmt.h': 'ч',
'fmt.m': 'мин',
'fmt.h_short': 'ч',
'fmt.m_short': 'м',
'fmt.s_short': 'с',
'fmt.paused': 'на паузе',
'fmt.through': 'Через'
}
export const en: Dict = {
// Sidebar / nav
'nav.today': 'Today',
'nav.exercises': 'Exercises',
'nav.games': 'Games',
'nav.challenges': 'Challenges',
'nav.settings': 'Settings',
'sidebar.slogan': 'Move with intention',
'sidebar.status_tracking': 'Activity tracking is on',
'titlebar.menu_aria': 'Menu',
'titlebar.minimize_aria': 'Minimize',
'titlebar.tray_aria': 'To tray',
'titlebar.close_aria': 'Close',
'titlebar.app_title': 'Exercise Reminder',
// Common buttons
'btn.add': 'Add',
'btn.new': 'New',
'btn.cancel': 'Cancel',
'btn.save': 'Save',
'btn.done': 'Done',
'btn.start': 'Start',
'btn.pause': 'Pause',
'btn.refresh': 'Refresh',
'btn.edit': 'Edit',
'btn.delete': 'Delete',
'btn.snooze_min': 'Snooze {n}m',
'btn.skip': 'Skip',
'btn.close': 'Close',
'btn.later': 'Later',
'btn.connect': 'Connect',
'btn.disconnect': 'Disconnect',
'btn.check': 'Check',
'btn.download': 'Download',
'btn.restart': 'Restart',
'btn.retry': 'Retry',
// Dashboard
'dashboard.kicker': 'Daily training',
'dashboard.title': 'Today',
'dashboard.stat.active': 'Active',
'dashboard.stat.active.of': 'of {total}',
'dashboard.stat.today_done': 'Today',
'dashboard.stat.today_done.subtitle': 'reps logged',
'dashboard.stat.streak': 'Streak',
'dashboard.stat.streak.subtitle': '{n} days in a row',
'dashboard.stat.next': 'Next in',
'dashboard.stat.next.now': 'Now',
'dashboard.stat.next.subtitle_paused': 'paused',
'dashboard.stat.next.subtitle_running': 'counting down',
'dashboard.stat.tracking': 'Match tracking',
'dashboard.stat.tracking.on': 'On',
'dashboard.stat.tracking.off': 'Off',
'dashboard.stat.tracking.subtitle_on': 'real-time',
'dashboard.stat.tracking.subtitle_off': 'disabled',
'dashboard.paused.title': 'Reminders paused',
'dashboard.paused.hint': 'Resume to continue countdown',
'dashboard.empty.title': 'Program is empty',
'dashboard.empty.hint': 'Add your first exercise to start',
// Exercises
'exercises.kicker': 'Program',
'exercises.title': 'Exercises',
'exercises.section.active': 'Active · {n}',
'exercises.section.disabled': 'Disabled · {n}',
'exercises.row.meta': '{reps} reps · {interval}',
'exercises.empty': 'Program is empty — add your first exercise',
// Exercise editor
'editor.exercise.title.new': 'New exercise',
'editor.exercise.title.edit': 'Edit',
'editor.exercise.preview.placeholder': 'Untitled',
'editor.exercise.preview.meta': '{reps} reps · every {min} min',
'editor.field.name': 'Name',
'editor.field.name.placeholder': 'Squats',
'editor.field.reps': 'Reps',
'editor.field.interval_min': 'Interval (min)',
'editor.field.icon': 'Icon',
// Challenges
'challenges.kicker': 'Per-match rules',
'challenges.title': 'Challenges',
'challenges.subtitle': 'Reps = {formula}',
'challenges.subtitle.formula': 'stat × multiplier',
'challenges.warning.no_games':
'Challenges trigger after a match. Connect a game in the Games tab.',
'challenges.section.all': 'All · {n}',
'challenges.empty':
'No challenges yet. Tie an exercise to a match statistic.',
// Challenge editor
'editor.challenge.title.new': 'New challenge',
'editor.challenge.title.edit': 'Edit',
'editor.field.challenge_name': 'Name',
'editor.field.challenge_name.placeholder': 'Squats per death',
'editor.field.game': 'Game',
'editor.field.stat': 'Statistic',
'editor.field.multiplier': 'Multiplier',
'editor.field.exercise_name': 'Exercise',
'editor.field.exercise_name.placeholder': 'Squats',
'editor.challenge.preview.kicker': 'Preview · 5 events',
'editor.challenge.preview.fallback': 'reps',
// Games
'games.kicker': 'Match tracking',
'games.title': 'Games',
'games.subtitle': 'Connect a game — challenges fire right after the match',
'games.subtitle.live': '{n} live',
'games.section.supported': 'Supported',
'games.scanning': 'Scanning installed games…',
'games.queued.body':
'Steam is running. The {opt} option will be added automatically next time Steam closes.',
'games.no_user.body':
'No logged-in Steam account (no userdata folder). Launch Steam once, then click “Connect”.',
'games.not_installed.hint': 'Install the game in Steam and click Refresh',
'games.dev.toggle': 'dev · simulate match end',
'games.badge.live': 'Live',
'games.badge.ready': 'Ready',
'games.badge.queued': 'Queued',
'games.badge.installed': 'Installed',
'games.badge.not_found': 'Not found',
// Settings
'settings.kicker': 'Configuration',
'settings.title': 'Settings',
'settings.section.reminders': 'Reminders',
'settings.section.quiet': 'Quiet hours',
'settings.section.window': 'Window & tray',
'settings.section.appearance': 'Appearance',
'settings.section.language': 'Language',
'settings.section.updates': 'Updates',
'settings.notification_mode.label': 'Notification mode',
'settings.notification_mode.hint': 'How a reminder appears',
'settings.notification_mode.modal': 'Window on top',
'settings.notification_mode.toast': 'System notification',
'settings.notification_mode.both': 'Window and notification',
'settings.sound.label': 'Notification sound',
'settings.sound.hint': 'Short beep on trigger',
'settings.snooze.label': '“Snooze” for',
'settings.snooze.hint': 'How many minutes to postpone',
'settings.snooze.1': '1 minute',
'settings.snooze.5': '5 minutes',
'settings.snooze.10': '10 minutes',
'settings.snooze.15': '15 minutes',
'settings.snooze.30': '30 minutes',
'settings.quiet.enabled.label': 'Quiet hours',
'settings.quiet.enabled.hint': 'Suppress reminders during the chosen window',
'settings.quiet.times.label': 'From and to',
'settings.quiet.times.hint': 'If `to` is earlier, the window wraps midnight',
'settings.quiet.days.label': 'Days of week',
'settings.quiet.days.hint': 'Quiet hours apply on the selected days',
'settings.tray.label': 'Minimize to tray',
'settings.tray.hint': 'Keep running in background when closed',
'settings.autostart.label': 'Start with Windows',
'settings.autostart.hint': 'Open at system login',
'settings.start_minimized.label': 'Start minimized',
'settings.start_minimized.hint': 'On autostart open straight to tray',
'settings.theme.label': 'Theme',
'settings.theme.hint': 'Light / dark / follow system',
'settings.theme.system': 'System',
'settings.theme.light': 'Light',
'settings.theme.dark': 'Dark',
'settings.language.label': 'Interface language',
'settings.language.hint': 'Applied immediately',
'settings.language.ru': 'Русский',
'settings.language.en': 'English',
'settings.loading': 'Loading…',
// Updater
'updater.unsupported': 'Auto-update unavailable',
'updater.unsupported.reason_dev': 'Auto-update is disabled in dev mode',
'updater.checking': 'Checking for updates…',
'updater.up_to_date': 'Up to date',
'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.downloading.title': 'Downloading update',
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s',
'updater.downloaded.title': 'Ready · v{v}',
'updater.downloaded.subtitle': 'Restart to apply',
'updater.error.title': 'Check failed',
'updater.idle.title': 'Check for updates',
'updater.idle.subtitle': 'Auto-check every hour',
// Reminder window
'reminder.kicker': 'Workout time',
'reminder.subkicker': 'Move',
'reminder.reps': 'reps',
'reminder.next_in': 'Next in {interval}',
'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',
'match.title.won': 'Victory',
'match.title.lost': 'Defeat',
'match.title.draw': 'Match finished',
'match.summary.minutes_count': '{n} min',
'match.summary.challenges_one': '{n} challenge',
'match.summary.challenges_few': '{n} challenges',
'match.summary.challenges_many': '{n} challenges',
'match.summary.all_done': 'all done',
'match.summary.remaining': '{n} left',
'match.total': 'Total',
'match.total_reps_suffix': 'reps',
// Format helpers
'fmt.now': 'now',
'fmt.h': 'h',
'fmt.m': 'min',
'fmt.h_short': 'h',
'fmt.m_short': 'm',
'fmt.s_short': 's',
'fmt.paused': 'paused',
'fmt.through': 'In'
}

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import { translate, translateN } from './index'
describe('translate', () => {
it('returns the matching string by key', () => {
expect(translate('ru', 'btn.save')).toBe('Сохранить')
expect(translate('en', 'btn.save')).toBe('Save')
})
it('falls back to the key when missing', () => {
expect(translate('ru', 'totally.unknown.key')).toBe('totally.unknown.key')
})
it('substitutes single variable', () => {
expect(translate('ru', 'btn.snooze_min', { n: 5 })).toBe('Отложить 5 мин')
expect(translate('en', 'btn.snooze_min', { n: 10 })).toBe('Snooze 10m')
})
it('substitutes multiple variables', () => {
expect(
translate('en', 'updater.downloading.subtitle', {
got: '1.5',
total: '80.0',
speed: '2.5'
})
).toBe('1.5 / 80.0 MB · 2.5 MB/s')
})
it('handles unknown language with fallback to ru', () => {
// @ts-expect-error testing fallback
expect(translate('fr', 'btn.save')).toBe('Сохранить')
})
})
describe('translateN (plural)', () => {
describe('russian plural rules', () => {
it('one: 1, 21, 101', () => {
expect(translateN('ru', 'match.summary.challenges', 1)).toBe('1 челлендж')
expect(translateN('ru', 'match.summary.challenges', 21)).toBe(
'21 челлендж'
)
expect(translateN('ru', 'match.summary.challenges', 101)).toBe(
'101 челлендж'
)
})
it('few: 2, 3, 4, 22, 23, 24', () => {
expect(translateN('ru', 'match.summary.challenges', 2)).toBe(
'2 челленджа'
)
expect(translateN('ru', 'match.summary.challenges', 3)).toBe(
'3 челленджа'
)
expect(translateN('ru', 'match.summary.challenges', 22)).toBe(
'22 челленджа'
)
})
it('many: 0, 5-20, 25-30, 111-114', () => {
expect(translateN('ru', 'match.summary.challenges', 0)).toBe(
'0 челленджей'
)
expect(translateN('ru', 'match.summary.challenges', 5)).toBe(
'5 челленджей'
)
expect(translateN('ru', 'match.summary.challenges', 11)).toBe(
'11 челленджей'
)
expect(translateN('ru', 'match.summary.challenges', 13)).toBe(
'13 челленджей'
)
expect(translateN('ru', 'match.summary.challenges', 20)).toBe(
'20 челленджей'
)
})
})
describe('english plural rules', () => {
it('one for 1', () => {
expect(translateN('en', 'match.summary.challenges', 1)).toBe(
'1 challenge'
)
})
it('many/other for anything else', () => {
expect(translateN('en', 'match.summary.challenges', 0)).toBe(
'0 challenges'
)
expect(translateN('en', 'match.summary.challenges', 2)).toBe(
'2 challenges'
)
expect(translateN('en', 'match.summary.challenges', 21)).toBe(
'21 challenges'
)
})
})
})

View File

@@ -0,0 +1,89 @@
import { useAppStore } from '../store/appStore'
import { ru, en, type Dict } from './dict'
import type { Language } from '@shared/types'
const dicts: Record<Language, Dict> = { ru, en }
export function getDict(lang: Language): Dict {
return dicts[lang] ?? ru
}
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.
* 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(lang: Language, key: string, vars?: TVars): string {
const dict = getDict(lang)
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) {
for (const k of Object.keys(vars)) {
s = s.split(`{${k}}`).join(String(vars[k]))
}
}
return s
}
/**
* Russian CLDR plural categories — covers nominal forms.
* one → 1, 21, 31, 41… (но не 11)
* few → 2-4, 22-24… (но не 12-14)
* many → 0, 5-20, 25-30…
*/
function pluralRu(n: number): 'one' | 'few' | 'many' {
// Always pluralize on the absolute value — a "-1" count is the same form as "1".
const abs = Math.abs(Math.trunc(n))
const mod10 = abs % 10
const mod100 = abs % 100
if (mod10 === 1 && mod100 !== 11) return 'one'
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 'few'
return 'many'
}
/**
* Plural lookup. Pass `keyBase` like `match.summary.challenges` — the
* function appends `_one`/`_few`/`_many` (ru) or `_one`/`_many` (en).
* The `n` value is exposed as `{n}` in the resulting string.
*/
export function translateN(
lang: Language,
keyBase: string,
n: number,
vars?: TVars
): string {
const form = lang === 'ru' ? pluralRu(n) : n === 1 ? 'one' : 'many'
return translate(lang, `${keyBase}_${form}`, { n, ...vars })
}
/* ---------------- React hook ---------------- */
export function useLang(): Language {
return useAppStore((s) => s.state?.settings?.language ?? 'ru')
}
export function useT(): {
t: (key: string, vars?: TVars) => string
tn: (keyBase: string, n: number, vars?: TVars) => string
lang: Language
} {
const lang = useLang()
return {
t: (key, vars) => translate(lang, key, vars),
tn: (keyBase, n, vars) => translateN(lang, keyBase, n, vars),
lang
}
}

View File

@@ -1,17 +1,26 @@
export function formatCountdown(ms: number): string {
if (ms <= 0) return 'сейчас'
import type { Language } from '@shared/types'
const SUFFIX = {
ru: { now: 'сейчас', h: 'ч', m: 'м', s: 'с', minLong: 'мин', hLong: 'ч' },
en: { now: 'now', h: 'h', m: 'm', s: 's', minLong: 'min', hLong: 'h' }
}
export function formatCountdown(ms: number, lang: Language = 'ru'): string {
const s = SUFFIX[lang] ?? SUFFIX.ru
if (!Number.isFinite(ms) || ms <= 0) return s.now
const totalSec = Math.floor(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) return `${h}ч ${String(m).padStart(2, '0')}м`
if (m > 0) return `${m}м ${String(s).padStart(2, '0')}с`
return `${s}с`
const sec = totalSec % 60
if (h > 0) return `${h}${s.h} ${String(m).padStart(2, '0')}${s.m}`
if (m > 0) return `${m}${s.m} ${String(sec).padStart(2, '0')}${s.s}`
return `${sec}${s.s}`
}
export function formatInterval(minutes: number): string {
if (minutes < 60) return `${minutes} мин`
export function formatInterval(minutes: number, lang: Language = 'ru'): string {
const s = SUFFIX[lang] ?? SUFFIX.ru
if (minutes < 60) return `${minutes} ${s.minLong}`
const h = Math.floor(minutes / 60)
const m = minutes % 60
return m === 0 ? `${h} ч` : `${h} ч ${m} мин`
return m === 0 ? `${h} ${s.hLong}` : `${h} ${s.hLong} ${m} ${s.minLong}`
}

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import type { Exercise, HistoryEntry } from '@shared/types'
import { currentStreak, dailyReps, dayKey, dailyRepsRange } from './history'
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 entry(
exerciseId: string,
ts: number,
action: 'done' | 'skip' | 'snooze' = 'done',
actualReps?: number
): HistoryEntry {
const e: HistoryEntry = { exerciseId, ts, action }
if (actualReps !== undefined) e.actualReps = actualReps
return e
}
describe('dayKey', () => {
it('returns local YYYY-MM-DD', () => {
// Midnight local time is "today" — we cannot pin exact value across
// timezones, so just assert the format.
expect(dayKey(Date.now())).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
})
describe('dailyReps', () => {
const today = Date.now()
const exs = [ex('a', 10), ex('b', 5)]
it('counts planned reps when actualReps absent', () => {
const hist = [entry('a', today), entry('b', today)]
expect(dailyReps(hist, exs, dayKey(today))).toBe(15)
})
it('counts actualReps when present (partial completion)', () => {
const hist = [entry('a', today, 'done', 7)]
expect(dailyReps(hist, exs, dayKey(today))).toBe(7)
})
it('ignores skip / snooze entries', () => {
const hist = [
entry('a', today, 'skip'),
entry('a', today, 'snooze'),
entry('b', today)
]
expect(dailyReps(hist, exs, dayKey(today))).toBe(5)
})
it('only counts the requested day', () => {
const yesterday = today - MS_DAY
const hist = [entry('a', today), entry('a', yesterday)]
expect(dailyReps(hist, exs, dayKey(today))).toBe(10)
expect(dailyReps(hist, exs, dayKey(yesterday))).toBe(10)
})
})
describe('currentStreak', () => {
const today = Date.now()
const day = (n: number): number => today - n * MS_DAY
it('returns 0 for empty history', () => {
expect(currentStreak([])).toBe(0)
})
it('returns 0 if no done in last 2 days', () => {
expect(currentStreak([entry('a', day(3))])).toBe(0)
})
it('counts consecutive days ending today', () => {
const hist = [
entry('a', day(0)),
entry('a', day(1)),
entry('a', day(2)),
entry('a', day(4)) // gap
]
expect(currentStreak(hist)).toBe(3)
})
it('allows yesterday as grace day if today not done yet', () => {
const hist = [entry('a', day(1)), entry('a', day(2))]
expect(currentStreak(hist)).toBe(2)
})
it('ignores skip and snooze', () => {
const hist = [entry('a', day(0), 'skip'), entry('a', day(1), 'snooze')]
expect(currentStreak(hist)).toBe(0)
})
it('multiple entries same day count once', () => {
const hist = [entry('a', day(0)), entry('b', day(0)), entry('a', day(1))]
expect(currentStreak(hist)).toBe(2)
})
})
describe('dailyRepsRange', () => {
it('always returns exactly `days` entries even if no history', () => {
expect(dailyRepsRange([], [], 7)).toHaveLength(7)
})
it('sums reps into correct buckets', () => {
const today = Date.now()
const exs = [ex('a', 10)]
const hist = [entry('a', today), entry('a', today - MS_DAY, 'done', 3)]
const range = dailyRepsRange(hist, exs, 7)
expect(range.at(-1)?.reps).toBe(10) // today
expect(range.at(-2)?.reps).toBe(3) // yesterday, partial
})
})

View File

@@ -0,0 +1,129 @@
import type { Exercise, HistoryEntry } from '@shared/types'
/** YYYY-MM-DD in local time. */
export function dayKey(ts: number): string {
const d = new Date(ts)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/** Today's local midnight. */
export function todayKey(): string {
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
* looks up exercise's planned `reps`.
*/
export function dailyReps(
entries: HistoryEntry[],
exercises: Exercise[],
dayKeyStr: string
): number {
const byId = new Map(exercises.map((e) => [e.id, e]))
let sum = 0
for (const e of entries) {
if (e.action !== 'done') continue
if (dayKey(e.ts) !== dayKeyStr) continue
sum += e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0
}
return sum
}
/**
* Map of `dayKey → totalReps` for the last `days` days (most recent last).
* Missing days are still included with value 0.
*/
export function dailyRepsRange(
entries: HistoryEntry[],
exercises: Exercise[],
days: number
): { key: string; date: Date; reps: number }[] {
const today = new Date()
today.setHours(0, 0, 0, 0)
const buckets = new Map<string, { date: Date; reps: number }>()
const byId = new Map(exercises.map((e) => [e.id, e]))
// 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--) {
const d = shiftDays(today, -i)
buckets.set(dayKey(d.getTime()), { date: d, reps: 0 })
}
for (const e of entries) {
if (e.action !== 'done') continue
const k = dayKey(e.ts)
const bucket = buckets.get(k)
if (!bucket) continue
const reps = e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0
bucket.reps += reps
}
return Array.from(buckets, ([key, { date, reps }]) => ({ key, date, reps }))
}
/**
* Current streak: consecutive days ending today (or yesterday — grace day)
* where at least one `done` was logged. Returns 0 if neither today nor
* yesterday has any done activity.
*/
export function currentStreak(entries: HistoryEntry[]): number {
const doneDays = new Set<string>()
for (const e of entries) {
if (e.action === 'done') doneDays.add(dayKey(e.ts))
}
if (doneDays.size === 0) return 0
const today = new Date()
today.setHours(0, 0, 0, 0)
const yesterday = shiftDays(today, -1)
const todayK = dayKey(today.getTime())
const yesterdayK = dayKey(yesterday.getTime())
// Start from today if active today, else yesterday (grace), else 0.
let cursor: Date | null = doneDays.has(todayK)
? today
: doneDays.has(yesterdayK)
? yesterday
: null
if (!cursor) return 0
let streak = 0
while (doneDays.has(dayKey(cursor.getTime()))) {
streak++
cursor = shiftDays(cursor, -1)
}
return streak
}
/** Total scheduled reps across all enabled exercises today (planned target). */
export function plannedRepsToday(exercises: Exercise[]): number {
// For now, "planned today" = sum of enabled exercises' reps × times per day
// approximation. A more honest target would count expected fires before
// midnight. We use a simple proxy: reps per exercise weighted by how often
// it'd fire in a day (1440 min / intervalMinutes).
let sum = 0
for (const e of exercises) {
if (!e.enabled) continue
const firesPerDay = Math.max(1, Math.floor(1440 / e.intervalMinutes))
sum += e.reps * firesPerDay
}
return sum
}

View File

@@ -24,13 +24,27 @@ export const ICON_CHOICES = [
export type IconName = (typeof ICON_CHOICES)[number]
const ICON_SET = new Set<string>(ICON_CHOICES)
/**
* 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({
name,
...props
}: { name: string } & LucideProps): JSX.Element {
const Cmp = (Lucide as unknown as Record<string, React.ComponentType<LucideProps>>)[
name
]
if (!ICON_SET.has(name)) {
if (import.meta.env.DEV) {
console.warn(`[Icon] unknown icon name "${name}" — falling back`)
}
return <Lucide.Activity {...props} />
}
const Cmp = (
Lucide as unknown as Record<string, React.ComponentType<LucideProps>>
)[name]
if (!Cmp) return <Lucide.Activity {...props} />
return <Cmp {...props} />
}

View File

@@ -10,6 +10,8 @@ const which = params.get('window') ?? 'main'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>{which === 'reminder' ? <ReminderApp /> : <App />}</ThemeProvider>
<ThemeProvider>
{which === 'reminder' ? <ReminderApp /> : <App />}
</ThemeProvider>
</React.StrictMode>
)

View File

@@ -6,13 +6,15 @@ import { Switch } from '../components/ui/Switch'
import { Modal } from '../components/ui/Modal'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { ICON_CHOICES, Icon } from '../lib/icon'
import { GAME_STATS, STAT_LABELS } from '@shared/types'
import { GAME_STATS, statLabel } from '@shared/types'
import type {
Challenge,
GameId,
GameStat,
GameStatus
GameStatus,
Language
} from '@shared/types'
import { useT } from '../i18n'
const GAME_NAMES: Record<GameId, string> = {
dota2: 'Dota 2'
@@ -35,6 +37,7 @@ export default function ChallengesPage(): JSX.Element {
const [games, setGames] = useState<GameStatus[]>([])
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Challenge | null>(null)
const { t, lang } = useT()
useEffect(() => {
void window.api.listGames().then(setGames)
@@ -49,13 +52,15 @@ export default function ChallengesPage(): JSX.Element {
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div>
<div className="text-[14px] text-text/65 font-semibold">
Правила за матч
{t('challenges.kicker')}
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Челленджи
{t('challenges.title')}
</h1>
<p className="text-[15px] text-text/65 mt-2 font-medium">
Повторов = <span className="font-mono-num font-semibold text-text">статистика × коэффициент</span>
{t('challenges.subtitle', {
formula: t('challenges.subtitle.formula')
})}
</p>
</div>
<Button
@@ -64,7 +69,7 @@ export default function ChallengesPage(): JSX.Element {
setEditorOpen(true)
}}
>
<Plus size={15} strokeWidth={2.5} /> Новый
<Plus size={15} strokeWidth={2.5} /> {t('btn.new')}
</Button>
</div>
@@ -74,15 +79,16 @@ export default function ChallengesPage(): JSX.Element {
<AlertTriangle size={18} strokeWidth={2.5} />
</div>
<div className="text-[14px] text-text/85 leading-relaxed font-medium">
Челленджи срабатывают после матча. Подключи игру во вкладке{' '}
<span className="font-semibold text-text">«Игры»</span>.
{t('challenges.warning.no_games')}
</div>
</div>
)}
{challenges.length > 0 ? (
<>
<SectionHeader title={`Все · ${challenges.length}`} />
<SectionHeader
title={t('challenges.section.all', { n: challenges.length })}
/>
<Card>
{challenges.map((c, i) => (
<Row
@@ -111,7 +117,7 @@ export default function ChallengesPage(): JSX.Element {
<Gamepad2 size={12} strokeWidth={2.4} />
{GAME_NAMES[c.gameId]} ·{' '}
<span className="font-mono-num font-semibold text-text">
{STAT_LABELS[c.stat]} × {c.multiplier}
{statLabel(c.stat, lang)} × {c.multiplier}
</span>{' '}
{c.exerciseName}
</div>
@@ -129,8 +135,8 @@ export default function ChallengesPage(): JSX.Element {
</>
) : (
<Card>
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
Челленджей пока нет. Привяжи упражнение к статистике матча.
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
{t('challenges.empty')}
</div>
</Card>
)}
@@ -138,6 +144,7 @@ export default function ChallengesPage(): JSX.Element {
<ChallengeEditor
open={editorOpen}
challenge={editing}
lang={lang}
onClose={() => setEditorOpen(false)}
onSave={async (draft) => {
if (editing) await window.api.updateChallenge(editing.id, draft)
@@ -153,15 +160,18 @@ export default function ChallengesPage(): JSX.Element {
function ChallengeEditor({
open,
challenge,
lang,
onClose,
onSave
}: {
open: boolean
challenge: Challenge | null
lang: Language
onClose: () => void
onSave: (draft: Draft) => void
}): JSX.Element {
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT)
const { t } = useT()
useEffect(() => {
if (challenge) {
@@ -190,30 +200,34 @@ function ChallengeEditor({
<Modal
open={open}
onClose={onClose}
title={challenge ? 'Редактировать' : 'Новый челлендж'}
title={
challenge
? t('editor.challenge.title.edit')
: t('editor.challenge.title.new')
}
footer={
<>
<Button variant="plain" onClick={onClose}>
Отмена
{t('btn.cancel')}
</Button>
<Button disabled={!canSave} onClick={() => onSave(draft)}>
Сохранить
{t('btn.save')}
</Button>
</>
}
>
<div className="space-y-5">
<Field label="Название">
<Field label={t('editor.field.challenge_name')}>
<input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="За смерти — приседания"
placeholder={t('editor.field.challenge_name.placeholder')}
className="ios-input"
autoFocus
/>
</Field>
<Field label="Игра">
<Field label={t('editor.field.game')}>
<select
value={draft.gameId}
onChange={(e) =>
@@ -230,7 +244,7 @@ function ChallengeEditor({
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Статистика">
<Field label={t('editor.field.stat')}>
<select
value={draft.stat}
onChange={(e) =>
@@ -240,12 +254,12 @@ function ChallengeEditor({
>
{GAME_STATS[draft.gameId].map((s) => (
<option key={s} value={s}>
{STAT_LABELS[s]}
{statLabel(s, lang)}
</option>
))}
</select>
</Field>
<Field label="Коэффициент">
<Field label={t('editor.field.multiplier')}>
<input
type="number"
step="0.5"
@@ -262,18 +276,18 @@ function ChallengeEditor({
</Field>
</div>
<Field label="Упражнение">
<Field label={t('editor.field.exercise_name')}>
<input
value={draft.exerciseName}
onChange={(e) =>
setDraft({ ...draft, exerciseName: e.target.value })
}
placeholder="Приседания"
placeholder={t('editor.field.exercise_name.placeholder')}
className="ios-input"
/>
</Field>
<Field label="Иконка">
<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">
{ICON_CHOICES.map((name) => (
<button
@@ -293,13 +307,12 @@ function ChallengeEditor({
</div>
</Field>
{/* Live preview */}
<div className="rounded-2xl bg-accent/8 p-4">
<div className="text-[11px] uppercase tracking-wider text-accent font-semibold mb-2">
Превью · 5 событий
{t('editor.challenge.preview.kicker')}
</div>
<div className="font-mono-num text-[14px] text-text/75 flex items-baseline gap-1.5 flex-wrap">
<span>5 {STAT_LABELS[draft.stat]}</span>
<span>5 {statLabel(draft.stat, lang)}</span>
<span className="text-text/40">×</span>
<span>{draft.multiplier}</span>
<span className="text-text/40">=</span>
@@ -307,12 +320,12 @@ function ChallengeEditor({
{previewReps}
</span>
<span className="text-text/55">
{draft.exerciseName.toLowerCase() || 'повторов'}
{draft.exerciseName.toLowerCase() ||
t('editor.challenge.preview.fallback')}
</span>
</div>
</div>
</div>
<style>{`
.ios-input {
width: 100%;

View File

@@ -1,24 +1,49 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus, Pause, Play, Flame, Activity } from 'lucide-react'
import { Plus, Pause, Play, Flame, Activity, TrendingUp } from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor'
import { HistoryHeatmap } from '../components/HistoryHeatmap'
import { Button } from '../components/ui/Button'
import type { Exercise } from '@shared/types'
import type { Exercise, HistoryEntry } from '@shared/types'
import { formatCountdown } from '../lib/format'
import { useT } from '../i18n'
import { currentStreak, dailyReps, todayKey } from '../lib/history'
export default function Dashboard(): JSX.Element {
const state = useAppStore((s) => s.state)
const ticks = useAppStore((s) => s.ticks)
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Exercise | null>(null)
const { 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 gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
// Local history mirror; reloaded only when exercises change (not on every
// tick or settings tweak — those don't affect history). When ticks/settings
// change we don't re-fetch.
const [history, setHistory] = useState<HistoryEntry[]>([])
useEffect(() => {
void window.api.getHistory().then(setHistory)
}, [exercises])
const todayDone = useMemo(
() => dailyReps(history, exercises, todayKey()),
[history, exercises]
)
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(() => {
void ticks // re-run on tick (Date.now() is the actual driver)
const enabled = exercises.filter((e) => e.enabled)
const next = enabled
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
@@ -57,70 +82,90 @@ export default function Dashboard(): JSX.Element {
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
}
const today = new Date().toLocaleDateString('ru-RU', {
weekday: 'long',
day: 'numeric',
month: 'long'
})
const today = new Date().toLocaleDateString(
lang === 'en' ? 'en-US' : 'ru-RU',
{ weekday: 'long', day: 'numeric', month: 'long' }
)
return (
<div className="h-full overflow-y-auto">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
{/* Hero — iOS Large Title */}
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div className="min-w-0">
<div className="text-[14px] text-text/65 font-semibold capitalize">
{today}
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Сегодня
{t('dashboard.title')}
</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="tinted" onClick={togglePause}>
{!paused ? (
<>
<Pause size={14} strokeWidth={2.5} /> Пауза
<Pause size={14} strokeWidth={2.5} /> {t('btn.pause')}
</>
) : (
<>
<Play size={14} strokeWidth={2.5} /> Старт
<Play size={14} strokeWidth={2.5} /> {t('btn.start')}
</>
)}
</Button>
<Button onClick={openCreate}>
<Plus size={15} strokeWidth={2.5} /> Добавить
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
</Button>
</div>
</div>
{/* Hero stat panel — Apple Fitness style */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
<HeroStat
tone="accent"
label="Активных"
value={`${stats.active}`}
subvalue={`из ${stats.total}`}
icon={<Activity size={14} strokeWidth={2.6} />}
label={t('dashboard.stat.today_done')}
value={`${todayDone}`}
subvalue={t('dashboard.stat.today_done.subtitle')}
icon={<TrendingUp size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone="info"
label="До следующего"
value={
stats.nextMs === Infinity
? '—'
: stats.nextMs <= 0
? 'Сейчас'
: formatCountdown(stats.nextMs)
}
subvalue={paused ? 'на паузе' : 'отсчёт идёт'}
tone={streak > 0 ? 'warning' : 'muted'}
label={t('dashboard.stat.streak')}
value={`${streak}`}
subvalue={t('dashboard.stat.streak.subtitle', { n: streak })}
icon={<Flame size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone={paused ? 'muted' : 'info'}
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={
paused
? '—'
: stats.nextMs === Infinity
? '—'
: stats.nextMs <= 0
? t('dashboard.stat.next.now')
: formatCountdown(stats.nextMs, lang)
}
subvalue={
paused
? t('dashboard.stat.next.subtitle_paused')
: t('dashboard.stat.next.subtitle_running')
}
icon={<Activity size={14} strokeWidth={2.6} />}
/>
<HeroStat
tone={gamesEnabled ? 'success' : 'muted'}
label="Трекинг матчей"
value={gamesEnabled ? 'On' : 'Off'}
subvalue={gamesEnabled ? 'в реальном времени' : 'выключен'}
label={t('dashboard.stat.tracking')}
value={
gamesEnabled
? t('dashboard.stat.tracking.on')
: t('dashboard.stat.tracking.off')
}
subvalue={
gamesEnabled
? t('dashboard.stat.tracking.subtitle_on')
: t('dashboard.stat.tracking.subtitle_off')
}
icon={
<span
className={[
@@ -132,7 +177,12 @@ export default function Dashboard(): JSX.Element {
/>
</div>
{/* Paused banner */}
{history.length > 0 && (
<div className="mb-8">
<HistoryHeatmap history={history} exercises={exercises} />
</div>
)}
{paused && (
<motion.div
initial={{ opacity: 0, y: -4 }}
@@ -144,19 +194,18 @@ export default function Dashboard(): JSX.Element {
</div>
<div className="flex-1 min-w-0">
<div className="text-[16px] font-semibold leading-tight">
Напоминания на паузе
{t('dashboard.paused.title')}
</div>
<div className="text-[14px] text-text/70 mt-1">
Возобнови, чтобы продолжить отсчёт
{t('dashboard.paused.hint')}
</div>
</div>
<Button variant="filled" size="sm" onClick={togglePause}>
<Play size={14} strokeWidth={2.5} /> Старт
<Play size={14} strokeWidth={2.5} /> {t('btn.start')}
</Button>
</motion.div>
)}
{/* Cards grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
<AnimatePresence>
{exercises.map((ex) => (
@@ -179,10 +228,10 @@ export default function Dashboard(): JSX.Element {
<Plus size={24} strokeWidth={2.5} />
</div>
<div className="font-display text-[20px] font-semibold">
Программа пуста
{t('dashboard.empty.title')}
</div>
<p className="text-[14px] text-text/55 mt-1">
Добавь первое упражнение, чтобы начать
{t('dashboard.empty.hint')}
</p>
</div>
)}
@@ -205,7 +254,7 @@ function HeroStat({
subvalue,
icon
}: {
tone: 'accent' | 'info' | 'success' | 'muted'
tone: 'accent' | 'info' | 'success' | 'warning' | 'muted'
label: string
value: string
subvalue?: string
@@ -218,7 +267,9 @@ function HeroStat({
? 'bg-info'
: tone === 'success'
? 'bg-success'
: 'bg-text/40'
: tone === 'warning'
? 'bg-warning'
: 'bg-text/40'
return (
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">

View File

@@ -7,12 +7,14 @@ import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { Icon } from '../lib/icon'
import { formatInterval } from '../lib/format'
import { useT } from '../i18n'
import type { Exercise } from '@shared/types'
export default function Exercises(): JSX.Element {
const exercises = useAppStore((s) => s.state?.exercises ?? [])
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<Exercise | null>(null)
const { t, lang } = useT()
const enabled = exercises.filter((e) => e.enabled)
const disabled = exercises.filter((e) => !e.enabled)
@@ -23,10 +25,10 @@ export default function Exercises(): JSX.Element {
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div>
<div className="text-[14px] text-text/65 font-semibold">
Программа
{t('exercises.kicker')}
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Упражнения
{t('exercises.title')}
</h1>
</div>
<Button
@@ -35,19 +37,25 @@ export default function Exercises(): JSX.Element {
setEditorOpen(true)
}}
>
<Plus size={15} strokeWidth={2.5} /> Добавить
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
</Button>
</div>
{enabled.length > 0 && (
<>
<SectionHeader title={`Активные · ${enabled.length}`} />
<SectionHeader
title={t('exercises.section.active', { n: enabled.length })}
/>
<Card className="mb-6">
{enabled.map((ex, i) => (
<ExerciseRow
key={ex.id}
exercise={ex}
last={i === enabled.length - 1}
meta={t('exercises.row.meta', {
reps: ex.reps,
interval: formatInterval(ex.intervalMinutes, lang)
})}
onEdit={() => {
setEditing(ex)
setEditorOpen(true)
@@ -60,13 +68,19 @@ export default function Exercises(): JSX.Element {
{disabled.length > 0 && (
<>
<SectionHeader title={`Выключенные · ${disabled.length}`} />
<SectionHeader
title={t('exercises.section.disabled', { n: disabled.length })}
/>
<Card>
{disabled.map((ex, i) => (
<ExerciseRow
key={ex.id}
exercise={ex}
last={i === disabled.length - 1}
meta={t('exercises.row.meta', {
reps: ex.reps,
interval: formatInterval(ex.intervalMinutes, lang)
})}
onEdit={() => {
setEditing(ex)
setEditorOpen(true)
@@ -80,7 +94,7 @@ export default function Exercises(): JSX.Element {
{exercises.length === 0 && (
<Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
Программа пуста добавь первое упражнение
{t('exercises.empty')}
</div>
</Card>
)}
@@ -103,21 +117,20 @@ export default function Exercises(): JSX.Element {
function ExerciseRow({
exercise,
last,
meta,
onEdit
}: {
exercise: Exercise
last: boolean
meta: string
onEdit: () => void
}): JSX.Element {
return (
<Row last={last}>
{/* Tinted icon plaque, iOS Settings style */}
<div
className={[
'w-9 h-9 rounded-lg grid place-items-center shrink-0',
exercise.enabled
? 'bg-accent text-white'
: 'bg-text/15 text-text/45'
exercise.enabled ? 'bg-accent text-white' : 'bg-text/15 text-text/45'
].join(' ')}
>
<Icon name={exercise.icon} size={18} strokeWidth={2.2} />
@@ -129,19 +142,15 @@ function ExerciseRow({
<div className="text-[16px] font-semibold truncate leading-tight">
{exercise.name}
</div>
<div className="text-[14px] text-text/65 mt-1 font-medium">
{exercise.reps} раз · {formatInterval(exercise.intervalMinutes)}
</div>
<div className="text-[14px] text-text/65 mt-1 font-medium">{meta}</div>
</button>
<Switch
checked={exercise.enabled}
onChange={(v) => window.api.toggleExercise(exercise.id, v)}
aria-label="Включить/выключить"
/>
<button
onClick={onEdit}
className="text-text/30 hover:text-text/60 transition-colors"
aria-label="Редактировать"
>
<ChevronRight size={16} />
</button>

View File

@@ -13,10 +13,12 @@ import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch'
import { Card, SectionHeader } from '../components/ui/Card'
import type { GameId, GameStatus } from '@shared/types'
import { useT } from '../i18n'
export default function GamesPage(): JSX.Element {
const [games, setGames] = useState<GameStatus[]>([])
const [busy, setBusy] = useState<GameId | null>(null)
const { t } = useT()
useEffect(() => {
void refresh()
@@ -52,9 +54,7 @@ export default function GamesPage(): JSX.Element {
}
}
const liveCount = games.filter(
(g) => g.enabled && g.integrationActive
).length
const liveCount = games.filter((g) => g.enabled && g.integrationActive).length
return (
<div className="h-full overflow-y-auto">
@@ -62,29 +62,29 @@ export default function GamesPage(): JSX.Element {
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div>
<div className="text-[14px] text-text/65 font-semibold">
Трекинг матчей
{t('games.kicker')}
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Игры
{t('games.title')}
</h1>
<p className="text-[15px] text-text/65 mt-2 font-medium leading-relaxed">
Подключи игру челленджи сработают сразу после матча
{t('games.subtitle')}
{liveCount > 0 && (
<>
{' · '}
<span className="text-success font-mono-num font-bold">
{liveCount} live
{t('games.subtitle.live', { n: liveCount })}
</span>
</>
)}
</p>
</div>
<Button variant="tinted" onClick={refresh}>
<RefreshCw size={14} strokeWidth={2.5} /> Обновить
<RefreshCw size={14} strokeWidth={2.5} /> {t('btn.refresh')}
</Button>
</div>
<SectionHeader title="Поддерживаемые" />
<SectionHeader title={t('games.section.supported')} />
<div className="space-y-4">
{games.map((g, i) => (
<motion.div
@@ -105,7 +105,7 @@ export default function GamesPage(): JSX.Element {
{games.length === 0 && (
<Card>
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
Сканируем установленные игры
{t('games.scanning')}
</div>
</Card>
)}
@@ -130,6 +130,7 @@ function GameCard({
onUninstall: () => void
onToggle: (v: boolean) => void
}): JSX.Element {
const { t } = useT()
const isLive =
game.installed &&
game.integrationActive &&
@@ -167,11 +168,7 @@ function GameCard({
</div>
</div>
{game.installed && game.integrationActive && (
<Switch
checked={game.enabled}
onChange={onToggle}
disabled={busy}
/>
<Switch checked={game.enabled} onChange={onToggle} disabled={busy} />
)}
</div>
@@ -183,11 +180,9 @@ function GameCard({
strokeWidth={2.4}
/>
<div className="text-text/85">
Steam запущен. Параметр{' '}
<code className="px-1.5 py-0.5 rounded-md bg-surface text-accent font-mono-num text-[13px] font-semibold">
{game.launchOption}
</code>{' '}
пропишется автоматически при следующем закрытии Steam.
{t('games.queued.body', {
opt: game.launchOption ?? '-gamestateintegration'
})}
</div>
</div>
)}
@@ -199,18 +194,14 @@ function GameCard({
className="text-destructive shrink-0 mt-0.5"
strokeWidth={2.4}
/>
<div className="text-text/85">
В Steam нет залогиненного аккаунта (нет папки{' '}
<code className="font-mono-num text-[13px] font-semibold">userdata</code>).
Запусти Steam один раз и нажми «Установить интеграцию».
</div>
<div className="text-text/85">{t('games.no_user.body')}</div>
</div>
)}
<div className="flex items-center flex-wrap gap-2 mt-4">
{game.installed && !game.integrationActive && (
<Button onClick={onInstall} disabled={busy} size="sm">
<Download size={14} strokeWidth={2.5} /> Подключить
<Download size={14} strokeWidth={2.5} /> {t('btn.connect')}
</Button>
)}
{game.integrationActive && (
@@ -220,12 +211,12 @@ function GameCard({
disabled={busy}
size="sm"
>
<Trash2 size={14} strokeWidth={2.5} /> Отключить
<Trash2 size={14} strokeWidth={2.5} /> {t('btn.disconnect')}
</Button>
)}
{!game.installed && (
<div className="text-[14px] text-text/65 font-medium">
Установи игру в Steam и нажми «Обновить»
{t('games.not_installed.hint')}
</div>
)}
</div>
@@ -240,6 +231,7 @@ function StatusBadge({
game: GameStatus
isLive: boolean
}): JSX.Element {
const { t } = useT()
if (isLive) {
return (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-success/15 text-success font-semibold inline-flex items-center gap-1.5">
@@ -247,57 +239,66 @@ function StatusBadge({
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
</span>
Live
{t('games.badge.live')}
</span>
)
}
if (game.integrationActive && game.launchOptionStatus === 'applied') {
return (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-accent/15 text-accent font-semibold inline-flex items-center gap-1.5">
<CheckCircle2 size={11} strokeWidth={2.5} /> Готово
<CheckCircle2 size={11} strokeWidth={2.5} /> {t('games.badge.ready')}
</span>
)
}
if (game.integrationActive && game.launchOptionStatus === 'queued') {
return (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-warning/15 text-warning font-semibold">
В очереди
{t('games.badge.queued')}
</span>
)
}
if (game.installed) {
return (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/70 font-semibold">
Установлена
{t('games.badge.installed')}
</span>
)
}
return (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/45 font-semibold">
Не найдена
{t('games.badge.not_found')}
</span>
)
}
function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
const [open, setOpen] = useState(false)
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')
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 (
<div className="mt-10">
<button
onClick={() => setOpen(!open)}
className="text-[12px] uppercase tracking-wider text-text/40 hover:text-text/70 font-mono-num font-medium transition-colors"
>
{open ? '▾' : '▸'} dev · симулировать конец матча
{open ? '▾' : '▸'} {t('games.dev.toggle')}
</button>
{open && (
<div className="mt-3 flex flex-wrap gap-2">
{(
[
{ label: '5 смертей', stats: { deaths: 5 } },
{ label: '10 смертей', stats: { deaths: 10 } },
{ label: '15 убийств', stats: { kills: 15 } },
{ label: '5 deaths', stats: { deaths: 5 } },
{ label: '10 deaths', stats: { deaths: 10 } },
{ label: '15 kills', stats: { kills: 15 } },
{
label: 'KDA 8/3/12',
stats: { kills: 8, deaths: 3, assists: 12 }
@@ -306,7 +307,7 @@ function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
).map((p) => (
<button
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"
>
{p.label}

View File

@@ -1,17 +1,22 @@
import { useEffect, useState } from 'react'
import { useAppStore } from '../store/appStore'
import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard'
import { useT } from '../i18n'
import type {
Language,
NotificationMode,
QuietHours,
Settings as SettingsType,
Theme
} from '@shared/types'
export default function SettingsPage(): JSX.Element {
const settings = useAppStore((s) => s.state?.settings)
const { t } = useT()
if (!settings)
return <div className="p-8 text-text/45">Загрузка</div>
return <div className="p-8 text-text/45">{t('settings.loading')}</div>
const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p)
@@ -22,67 +27,112 @@ export default function SettingsPage(): JSX.Element {
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="mb-8">
<div className="text-[14px] text-text/65 font-semibold">
Конфигурация
{t('settings.kicker')}
</div>
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
Настройки
{t('settings.title')}
</h1>
</div>
{/* Reminders */}
<SectionHeader title="Напоминания" />
<SectionHeader title={t('settings.section.language')} />
<Card className="mb-6">
<SelectRow
label="Режим уведомления"
hint="Как должно выглядеть напоминание"
value={settings.notificationMode}
onChange={(v) => patch({ notificationMode: v as NotificationMode })}
label={t('settings.language.label')}
hint={t('settings.language.hint')}
value={settings.language}
onChange={(v) => patch({ language: v as Language })}
options={[
{ value: 'modal', label: 'Окно поверх всех' },
{ value: 'toast', label: 'Системное уведомление' },
{ value: 'both', label: 'Окно и уведомление' }
]}
/>
<ToggleRow
label="Звук уведомления"
hint="Короткий сигнал при срабатывании"
checked={settings.soundEnabled}
onChange={(v) => patch({ soundEnabled: v })}
/>
<SelectRow
label="«Отложить» на"
hint="Сколько минут добавлять при отложении"
value={String(settings.snoozeMinutes)}
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
options={[
{ value: '1', label: '1 минута' },
{ value: '5', label: '5 минут' },
{ value: '10', label: '10 минут' },
{ value: '15', label: '15 минут' },
{ value: '30', label: '30 минут' }
{ value: 'ru', label: t('settings.language.ru') },
{ value: 'en', label: t('settings.language.en') }
]}
last
/>
</Card>
{/* Window */}
<SectionHeader title="Окно и трей" />
<SectionHeader title={t('settings.section.reminders')} />
<Card className="mb-6">
<SelectRow
label={t('settings.notification_mode.label')}
hint={t('settings.notification_mode.hint')}
value={settings.notificationMode}
onChange={(v) => patch({ notificationMode: v as NotificationMode })}
options={[
{
value: 'modal',
label: t('settings.notification_mode.modal')
},
{
value: 'toast',
label: t('settings.notification_mode.toast')
},
{
value: 'both',
label: t('settings.notification_mode.both')
}
]}
/>
<ToggleRow
label={t('settings.sound.label')}
hint={t('settings.sound.hint')}
checked={settings.soundEnabled}
onChange={(v) => patch({ soundEnabled: v })}
/>
<SelectRow
label={t('settings.snooze.label')}
hint={t('settings.snooze.hint')}
value={String(settings.snoozeMinutes)}
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
options={[
{ value: '1', label: t('settings.snooze.1') },
{ value: '5', label: t('settings.snooze.5') },
{ value: '10', label: t('settings.snooze.10') },
{ value: '15', label: t('settings.snooze.15') },
{ value: '30', label: t('settings.snooze.30') }
]}
last
/>
</Card>
<SectionHeader title={t('settings.section.quiet')} />
<Card className="mb-6">
<ToggleRow
label="Сворачивать в трей"
hint="При закрытии остаётся работать в фоне"
label={t('settings.quiet.enabled.label')}
hint={t('settings.quiet.enabled.hint')}
checked={settings.quietHours.enabled}
onChange={(v) =>
patch({ quietHours: { ...settings.quietHours, enabled: v } })
}
/>
<QuietTimesRow
qh={settings.quietHours}
onChange={(qh) => patch({ quietHours: qh })}
disabled={!settings.quietHours.enabled}
/>
<QuietDaysRow
qh={settings.quietHours}
onChange={(qh) => patch({ quietHours: qh })}
disabled={!settings.quietHours.enabled}
last
/>
</Card>
<SectionHeader title={t('settings.section.window')} />
<Card className="mb-6">
<ToggleRow
label={t('settings.tray.label')}
hint={t('settings.tray.hint')}
checked={settings.minimizeToTray}
onChange={(v) => patch({ minimizeToTray: v })}
/>
<ToggleRow
label="Запускать с Windows"
hint="Открывать при входе в систему"
label={t('settings.autostart.label')}
hint={t('settings.autostart.hint')}
checked={settings.startWithWindows}
onChange={(v) => patch({ startWithWindows: v })}
/>
<ToggleRow
label="Запускать свёрнутым"
hint="При автозапуске открывать сразу в трее"
label={t('settings.start_minimized.label')}
hint={t('settings.start_minimized.hint')}
checked={settings.startMinimized}
onChange={(v) => patch({ startMinimized: v })}
disabled={!settings.startWithWindows}
@@ -90,24 +140,23 @@ export default function SettingsPage(): JSX.Element {
/>
</Card>
{/* Appearance */}
<SectionHeader title="Внешний вид" />
<SectionHeader title={t('settings.section.appearance')} />
<Card className="mb-6">
<SelectRow
label="Тема"
hint="Светлая / тёмная / как в системе"
label={t('settings.theme.label')}
hint={t('settings.theme.hint')}
value={settings.theme}
onChange={(v) => patch({ theme: v as Theme })}
options={[
{ value: 'system', label: 'Как в системе' },
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' }
{ value: 'system', label: t('settings.theme.system') },
{ value: 'light', label: t('settings.theme.light') },
{ value: 'dark', label: t('settings.theme.dark') }
]}
last
/>
</Card>
<SectionHeader title="Обновления" />
<SectionHeader title={t('settings.section.updates')} />
<UpdaterCard />
</div>
</div>
@@ -144,6 +193,136 @@ function ToggleRow({
)
}
function QuietTimesRow({
qh,
onChange,
disabled,
last = false
}: {
qh: QuietHours
onChange: (next: QuietHours) => void
disabled?: boolean
last?: boolean
}): JSX.Element {
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 (
<Row last={last} className={disabled ? 'opacity-50' : ''}>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.quiet.times.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.quiet.times.hint')}
</div>
</div>
<div className="flex items-center gap-2">
<input
type="time"
value={from}
disabled={disabled}
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"
/>
<span className="text-text/45 text-[14px]"></span>
<input
type="time"
value={to}
disabled={disabled}
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"
/>
</div>
</Row>
)
}
function QuietDaysRow({
qh,
onChange,
disabled,
last = false
}: {
qh: QuietHours
onChange: (next: QuietHours) => void
disabled?: boolean
last?: boolean
}): JSX.Element {
const { t } = useT()
// Indices match Date.getDay() (0 = Sunday) — same convention as
// src/shared/types.ts QuietHours.days values.
const labels = [0, 1, 2, 3, 4, 5, 6].map((i) => t(`weekday.short.${i}`))
function toggle(d: number): void {
const set = new Set(qh.days)
if (set.has(d)) set.delete(d)
else set.add(d)
// 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 (
<Row last={last} className={disabled ? 'opacity-50' : ''}>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold leading-tight">
{t('settings.quiet.days.label')}
</div>
<div className="text-[13px] text-text/65 mt-1 leading-snug">
{t('settings.quiet.days.hint')}
</div>
</div>
<div className="flex items-center gap-1 flex-wrap justify-end max-w-[60%]">
{labels.map((label, d) => {
const on = qh.days.includes(d)
return (
<button
key={d}
type="button"
disabled={disabled}
onClick={() => toggle(d)}
className={[
'h-7 min-w-[28px] px-1.5 rounded-full text-[11px] font-semibold transition-all',
on
? 'bg-accent text-white'
: 'bg-surface-2 text-text/55 hover:text-text'
].join(' ')}
>
{label}
</button>
)
})}
</div>
</Row>
)
}
function SelectRow({
label,
hint,

View File

@@ -1,7 +1,11 @@
import { ReactNode, useEffect, useState } from 'react'
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 [osTheme, setOsTheme] = useState<'light' | 'dark'>('dark')

View File

@@ -31,7 +31,9 @@ export const useAppStore = create<Store>((set) => ({
export function subscribeToBackend(): () => void {
const store = useAppStore.getState()
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))
return () => {
u1()

View File

@@ -6,22 +6,22 @@
:root {
/* Brand & semantic colors (iOS system palette) */
--accent: 255 107 53; /* Apple Fitness Move orange */
--accent-2: 255 45 85; /* systemPink */
--success: 52 199 89; /* systemGreen */
--warning: 255 159 10; /* systemOrange dark */
--destructive: 255 59 48; /* systemRed */
--info: 0 122 255; /* systemBlue */
--accent: 255 107 53; /* Apple Fitness Move orange */
--accent-2: 255 45 85; /* systemPink */
--success: 52 199 89; /* systemGreen */
--warning: 255 159 10; /* systemOrange dark */
--destructive: 255 59 48; /* systemRed */
--info: 0 122 255; /* systemBlue */
color-scheme: light dark;
}
/* Light — polished iOS groupedBackground with warm undertone */
: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-2: 240 240 245; /* subtle separation for inputs/sections */
--text: 17 17 19; /* not pure black — softer */
--surface-2: 240 240 245; /* subtle separation for inputs/sections */
--text: 17 17 19; /* not pure black — softer */
--text-secondary: 60 60 67;
--text-tertiary: 60 60 67;
--hairline: 60 60 67;
@@ -114,8 +114,8 @@ body {
}
.font-mono-num {
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code',
Menlo, monospace;
font-family:
'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
font-variant-numeric: tabular-nums;
font-feature-settings: 'ss02', 'ss19', 'zero';
letter-spacing: -0.01em;

View File

@@ -42,6 +42,10 @@ export const IPC = {
updaterDownload: 'updater:download',
updaterInstall: 'updater:install',
// History
getHistory: 'history:get',
clearHistory: 'history:clear',
// events from main → renderer
evtTick: 'evt:tick',
evtFire: 'evt:fire',

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import { isQuietAt, type QuietHours } from './types'
function at(iso: string): Date {
return new Date(iso)
}
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6]
describe('isQuietAt', () => {
it('returns false when disabled', () => {
const qh: QuietHours = {
enabled: false,
from: '00:00',
to: '23:59',
days: ALL_DAYS
}
expect(isQuietAt(qh, at('2026-05-17T12:00:00'))).toBe(false)
})
it('same-day window: inside is quiet, outside is not', () => {
const qh: QuietHours = {
enabled: true,
from: '13:00',
to: '14:00',
days: ALL_DAYS
}
expect(isQuietAt(qh, at('2026-05-17T13:30:00'))).toBe(true)
expect(isQuietAt(qh, at('2026-05-17T12:59:00'))).toBe(false)
expect(isQuietAt(qh, at('2026-05-17T14:00:00'))).toBe(false) // exclusive end
})
it('wrap-around window 22:00 → 08:00', () => {
const qh: QuietHours = {
enabled: true,
from: '22:00',
to: '08:00',
days: ALL_DAYS
}
expect(isQuietAt(qh, at('2026-05-17T23:00:00'))).toBe(true)
expect(isQuietAt(qh, at('2026-05-17T02:00:00'))).toBe(true)
expect(isQuietAt(qh, at('2026-05-17T07:59:00'))).toBe(true)
expect(isQuietAt(qh, at('2026-05-17T08:00:00'))).toBe(false)
expect(isQuietAt(qh, at('2026-05-17T15:00:00'))).toBe(false)
expect(isQuietAt(qh, at('2026-05-17T21:59:00'))).toBe(false)
})
it('day filtering: window inactive on excluded days', () => {
const qh: QuietHours = {
enabled: true,
from: '13:00',
to: '14:00',
days: [1, 2, 3, 4, 5] // weekdays only
}
// 2026-05-17 is Sunday (day 0)
expect(isQuietAt(qh, at('2026-05-17T13:30:00'))).toBe(false)
// 2026-05-18 is Monday (day 1)
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', () => {
const qh: QuietHours = {
enabled: true,
from: '12:00',
to: '12:00',
days: ALL_DAYS
}
expect(isQuietAt(qh, at('2026-05-17T12:00:00'))).toBe(false)
expect(isQuietAt(qh, at('2026-05-17T12:00:01'))).toBe(false)
})
})

View File

@@ -11,6 +11,20 @@ export type Exercise = {
export type NotificationMode = 'toast' | 'modal' | 'both'
export type Theme = 'light' | 'dark' | 'system'
export type Language = 'ru' | 'en'
/**
* Hours when reminders are silenced. `from`/`to` are "HH:MM" 24h strings,
* `days` are weekday indices 0=Sun..6=Sat. Empty `days` = applies every day.
* If `to <= from` the window wraps across midnight (e.g. 22:00 → 07:00).
*/
export type QuietHours = {
enabled: boolean
from: string
to: string
/** Days when the quiet window is active. */
days: number[]
}
export type Settings = {
globalEnabled: boolean
@@ -20,7 +34,9 @@ export type Settings = {
minimizeToTray: boolean
startMinimized: boolean
theme: Theme
language: Language
snoozeMinutes: number
quietHours: QuietHours
}
export type AppState = {
@@ -28,6 +44,18 @@ export type AppState = {
settings: Settings
challenges: Challenge[]
gamesEnabled: Partial<Record<GameId, boolean>>
history?: HistoryEntry[]
}
export type HistoryAction = 'done' | 'skip' | 'snooze'
export type HistoryEntry = {
/** ms epoch */
ts: number
exerciseId: string
action: HistoryAction
/** When user did less than planned. Only meaningful for `done`. */
actualReps?: number
}
export type Tick = {
@@ -71,6 +99,19 @@ export const STAT_LABELS: Record<GameStat, string> = {
duration_min: 'минут матча'
}
export const STAT_LABELS_EN: Record<GameStat, string> = {
deaths: 'deaths',
kills: 'kills',
assists: 'assists',
last_hits: 'last hits',
denies: 'denies',
duration_min: 'match minutes'
}
export function statLabel(stat: GameStat, lang: Language): string {
return (lang === 'en' ? STAT_LABELS_EN : STAT_LABELS)[stat]
}
export type Challenge = {
id: string
name: string
@@ -89,10 +130,10 @@ export type GameStatus = {
name: string
installed: boolean
installPath?: string
integrationActive: boolean // cfg installed + listener running
launchOption?: string // e.g. "-gamestateintegration"
integrationActive: boolean // cfg installed + listener running
launchOption?: string // e.g. "-gamestateintegration"
launchOptionStatus: LaunchOptionStatus
steamRunning?: boolean // helps the UI explain queued state
steamRunning?: boolean // helps the UI explain queued state
enabled: boolean
}
@@ -103,7 +144,10 @@ export type ChallengeResult = {
exerciseName: string
reps: number
statValue: number
/** Pre-localised label for backward compat; renderer prefers `stat`. */
statLabel: string
/** Stat key; renderer uses this to localise on demand. */
stat?: GameStat
}
export type MatchSummary = {
@@ -122,20 +166,98 @@ export const DEFAULT_SETTINGS: Settings = {
minimizeToTray: true,
startMinimized: false,
theme: 'light',
snoozeMinutes: 5
language: 'ru',
snoozeMinutes: 5,
quietHours: {
enabled: false,
from: '22:00',
to: '08:00',
days: [0, 1, 2, 3, 4, 5, 6]
}
}
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
* windows (e.g. 22:00 → 08:00) AND day-of-week filtering correctly: when the
* 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 {
if (!qh.enabled) return false
const fromMin = parseHHMM(qh.from)
const toMin = parseHHMM(qh.to)
if (fromMin === null || toMin === null) 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) {
// Same-day window — start day is `todayDow`.
if (!dayActive(todayDow)) return false
return cur >= fromMin && cur < toMin
}
// Wrap-around window. Either:
// - 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'>[] = [
{ name: 'Приседания', reps: 10, icon: 'Activity', intervalMinutes: 30, enabled: true },
{ name: 'Отжимания', reps: 10, icon: 'Dumbbell', intervalMinutes: 45, enabled: true },
{ name: 'Растяжка спины', reps: 1, icon: 'StretchHorizontal', intervalMinutes: 60, enabled: false }
{
name: 'Приседания',
reps: 10,
icon: 'Activity',
intervalMinutes: 30,
enabled: true
},
{
name: 'Отжимания',
reps: 10,
icon: 'Dumbbell',
intervalMinutes: 45,
enabled: true
},
{
name: 'Растяжка спины',
reps: 1,
icon: 'StretchHorizontal',
intervalMinutes: 60,
enabled: false
}
]
export type UpdaterStatus =
| { kind: 'idle' }
| { kind: 'idle'; lastCheckedAt?: number }
| { kind: 'unsupported'; reason: string }
| { kind: 'checking' }
| { kind: 'not-available'; currentVersion: string }
| { kind: 'not-available'; currentVersion: string; lastCheckedAt?: number }
| { kind: 'available'; version: string; releaseDate?: string }
| {
kind: 'downloading'
@@ -146,4 +268,3 @@ export type UpdaterStatus =
}
| { kind: 'downloaded'; version: string }
| { kind: 'error'; message: string }

View File

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

View File

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