feat(robustness+ui): отказоустойчивость main, тесты, a11y-полировка, лицензия

Надёжность main-процесса:
- глобальные uncaughtException/unhandledRejection (лог + flushNow)
- safeHandle/safeOn вокруг всех IPC-хендлеров (не падаем молча, generic-ошибка наружу)
- таймаут 4s на tasklist, Atomics.wait вместо busy-spin на exit-записи
- единый log.error для фоновых сбоев вместо console.error/тишины

Тесты (178 -> 203): meeting-detect, scheduler-gating, store (миграции/карантин/cap).

UI/UX:
- prefers-reduced-motion через MotionConfig + CSS media-блок
- Spinner/Skeleton примитивы, loading-состояния вместо пустых заглушек
- aria-live анонсы достижений и выполнения (useAnnounce)
- оформленные пустые состояния, клавиатура в меню ExerciseCard

Лицензия: проприетарный LICENSE + правка README/CLAUDE.md, счётчик тестов.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AnRil
2026-05-29 13:23:53 +07:00
parent 8a62ebc9fe
commit 46b3d59b66
24 changed files with 979 additions and 98 deletions

View File

@@ -12,7 +12,7 @@
- **Build**: electron-vite 2 + Vite 5 + electron-builder 25 (NSIS, x64 only) - **Build**: electron-vite 2 + Vite 5 + electron-builder 25 (NSIS, x64 only)
- **UI**: React 18 + TypeScript 5 + Tailwind 3 + framer-motion + react-router (HashRouter) + zustand 5 - **UI**: React 18 + TypeScript 5 + Tailwind 3 + framer-motion + react-router (HashRouter) + zustand 5
- **Auto-update**: electron-updater 6, generic provider, фиксированный канал - **Auto-update**: electron-updater 6, generic provider, фиксированный канал
- **Тесты**: Vitest 4 (53 теста, все зелёные) - **Тесты**: Vitest 4 (203 теста, все зелёные)
- **Lint/format**: ESLint 8 (flat-ish .eslintrc.cjs) + Prettier 3 + EditorConfig - **Lint/format**: ESLint 8 (flat-ish .eslintrc.cjs) + Prettier 3 + EditorConfig
- **Иконки**: lucide-react (whitelisted lookup через `ICON_CHOICES`) - **Иконки**: lucide-react (whitelisted lookup через `ICON_CHOICES`)
- **Шрифты**: Plus Jakarta Sans, Bricolage Grotesque, JetBrains Mono (Google Fonts CDN) - **Шрифты**: Plus Jakarta Sans, Bricolage Grotesque, JetBrains Mono (Google Fonts CDN)
@@ -38,8 +38,14 @@
- string cap 200 chars, enum-валидация для theme/lang/notify-mode/stat - string cap 200 chars, enum-валидация для theme/lang/notify-mode/stat
- HH:MM regex для quietHours, dedup days - HH:MM regex для quietHours, dedup days
- Strip `id` из updateExercise/updateChallenge patch - Strip `id` из updateExercise/updateChallenge patch
- **Error-boundary**: все хендлеры обёрнуты в `safeHandle`/`safeOn` (`src/main/ipc.ts`) — исключение логируется в `latest.log`, наружу уходит generic `ipc-failed` (не падаем молча, не утекают детали)
- **Dev-only**: `dev:simulateMatchEnd` gated на `!app.isPackaged` - **Dev-only**: `dev:simulateMatchEnd` gated на `!app.isPackaged`
### Отказоустойчивость main
- **Глобальные хендлеры** в `src/main/index.ts`: `uncaughtException` (лог + `flushNow`) и `unhandledRejection` (лог) — процесс не исчезает молча
- **tasklist timeout** 4s в `meeting-detect.ts` (зависший child не копится)
- **Sync write на exit** через `Atomics.wait` (не busy-spin) в `store.ts`
### Auto-update (КРИТИЧНО) ### Auto-update (КРИТИЧНО)
- **Фиксированный URL канала**: `…/releases/download/update-channel/latest.yml` — никогда не меняется - **Фиксированный URL канала**: `…/releases/download/update-channel/latest.yml` — никогда не меняется
- **НЕ** `…/releases/download/v${version}/…` (старая схема ломалась: установленная копия видела только свой релиз) - **НЕ** `…/releases/download/v${version}/…` (старая схема ломалась: установленная копия видела только свой релиз)
@@ -122,18 +128,32 @@ npm run release -- -Bump patch
| `src/renderer/src/pages/Dashboard.tsx` | главная | | `src/renderer/src/pages/Dashboard.tsx` | главная |
| `src/renderer/src/ReminderApp.tsx` | окно напоминания | | `src/renderer/src/ReminderApp.tsx` | окно напоминания |
## Тесты (53) ## Тесты (203)
``` ```
src/shared/types.test.ts (4) src/main/validate.test.ts (68)
src/shared/quiet-hours.test.ts (7) src/renderer/src/lib/history.test.ts (31)
src/renderer/src/lib/format.test.ts (8) src/renderer/src/i18n/i18n.test.ts (15)
src/renderer/src/lib/history.test.ts (13) src/renderer/src/lib/format.test.ts (14)
src/main/games/vdf.test.ts (11) src/main/games/vdf.test.ts (11)
src/renderer/src/i18n/i18n.test.ts (10) src/main/store.test.ts (10) ← main: миграции/карантин/cap
src/renderer/src/lib/achievements.test.ts (10)
src/shared/release-notes.test.ts (9)
src/main/scheduler.test.ts (8) ← main: gating-логика
src/main/meeting-detect.test.ts (7) ← main: детект ВКС + кэш/timeout
src/shared/quiet-hours.test.ts (7)
src/main/adaptive.test.ts (6)
src/shared/types.test.ts (4)
src/renderer/src/lib/icon-choices.test.ts (3)
``` ```
Покрываются: helpers, история/стрики (DST), тихие часы (wrap+filter), VDF-парсер Steam, i18n с плюрализацией, дефолты. Покрываются: IPC-валидация, persistence (миграции/карантин/cap), scheduler-gating
(тихие часы/ВКС/daily-goal), детект ВКС (мок child_process), helpers, история/стрики
(DST), тихие часы (wrap+filter), VDF-парсер Steam, достижения, i18n с плюрализацией,
дефолты.
Паттерн для main-тестов: `vi.mock('electron'|'./store'|'node:child_process')` +
`vi.resetModules()` + dynamic import (сброс module-level состояния между тестами).
## Технический долг (не для пользователя) ## Технический долг (не для пользователя)

49
LICENSE Normal file
View File

@@ -0,0 +1,49 @@
Exercise Reminder (Laude)
Proprietary Software License
Copyright (c) 2026 AnRil. All rights reserved.
1. Definitions
"Software" means the Exercise Reminder (Laude) application, including its
source code, binaries, installers, assets, and documentation, in any form.
"Author" means the copyright holder named above.
2. Grant
The Author grants you a personal, non-exclusive, non-transferable, revocable
license to install and use the Software on devices you own or control, for
your own personal, non-commercial purposes.
3. Restrictions
Except as expressly permitted by this license or by mandatory applicable
law, you may NOT, without the Author's prior written permission:
(a) copy, publish, distribute, sublicense, sell, rent, or lease the
Software or any part of it;
(b) modify, adapt, translate, or create derivative works of the Software;
(c) reverse engineer, decompile, or disassemble the Software, or otherwise
attempt to derive its source code, except to the extent this
restriction is prohibited by applicable law;
(d) remove or alter any copyright, trademark, or other proprietary notices.
4. Ownership
The Software is licensed, not sold. The Author retains all right, title, and
interest in and to the Software, including all intellectual property rights.
No rights are granted other than those expressly set out in this license.
5. No Warranty
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
6. Limitation of Liability
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING
FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
7. Termination
This license terminates automatically if you breach any of its terms. Upon
termination you must stop using the Software and delete all copies in your
possession.
For permissions beyond the scope of this license, contact the Author through
the project repository.

View File

@@ -3,7 +3,7 @@
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений. Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
[![release](https://img.shields.io/badge/release-v0.5.8-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest) [![release](https://img.shields.io/badge/release-v0.5.8-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
[![tests](https://img.shields.io/badge/tests-178%20passing-green)]() [![tests](https://img.shields.io/badge/tests-203%20passing-green)]()
[![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]() [![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]()
## Что внутри ## Что внутри
@@ -66,21 +66,32 @@ npm run release -- -Bump patch # bump версии + tag + push + upload в G
## Тесты ## Тесты
``` ```
src/shared/types.test.ts (4) src/main/validate.test.ts (68)
src/shared/quiet-hours.test.ts (5) src/renderer/src/lib/history.test.ts (31)
src/renderer/src/lib/format.test.ts (8) src/renderer/src/i18n/i18n.test.ts (15)
src/renderer/src/lib/history.test.ts (13) src/renderer/src/lib/format.test.ts (14)
src/main/games/vdf.test.ts (11) src/main/games/vdf.test.ts (11)
src/renderer/src/i18n/i18n.test.ts (10) src/main/store.test.ts (10)
───────────────────────────────────────── src/renderer/src/lib/achievements.test.ts (10)
51 ✓ src/shared/release-notes.test.ts (9)
src/main/scheduler.test.ts (8)
src/main/meeting-detect.test.ts (7)
src/shared/quiet-hours.test.ts (7)
src/main/adaptive.test.ts (6)
src/shared/types.test.ts (4)
src/renderer/src/lib/icon-choices.test.ts (3)
──────────────────────────────────────────
203 ✓
``` ```
Покрытие: чистые helpers (форматирование, история/стрики, тихие часы, парсер VDF для Steam-конфигов), i18n с плюрализацией для RU/EN, дефолты shared-типов. Покрытие: IPC-валидация, persistence (миграции, карантин битого JSON, history cap), scheduler-гейтинг (тихие часы, ВКС-пауза, daily-goal), детект ВКС, история/стрики (DST), тихие часы (wrap), парсер VDF для Steam-конфигов, достижения, i18n с плюрализацией RU/EN, дефолты shared-типов.
## Лицензия ## Лицензия
Пока не указана. По умолчанию все права защищены. Если хочешь форк/использование — открой issue. Проприетарная — все права защищены. Личное некоммерческое использование
разрешено; копирование, распространение, модификация и реверс-инжиниринг — без
письменного разрешения автора. Полный текст — в файле [LICENSE](LICENSE). По
вопросам использования за рамками лицензии открой issue в репозитории.
## Stack ## Stack

View File

@@ -13,9 +13,26 @@ import { broadcastState } from './state-actions'
import { startGamesRegistry, stopGamesRegistry } from './games/registry' import { startGamesRegistry, stopGamesRegistry } from './games/registry'
import { initUpdater, stopUpdater } from './updater' import { initUpdater, stopUpdater } from './updater'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import { log } from './logger'
const APP_ID = 'com.anril.exercise-reminder' const APP_ID = 'com.anril.exercise-reminder'
// Глобальная сеть безопасности: без этих обработчиков необработанное
// исключение/rejection в main-процессе валит приложение молча — пользователь
// видит, что окно просто исчезло, а в логах пусто. Логируем всё в latest.log.
// uncaughtException дополнительно флашит state, чтобы не потерять данные.
process.on('uncaughtException', (err) => {
log.error('[fatal] uncaughtException', err)
try {
flushNow()
} catch {
// flush сам может бросить (диск/AV) — мы уже в аварийном пути, глушим.
}
})
process.on('unhandledRejection', (reason) => {
log.error('[fatal] unhandledRejection', reason)
})
// Must be set BEFORE app.whenReady() for Windows toasts to show // Must be set BEFORE app.whenReady() for Windows toasts to show
// the correct app name / icon in Action Center. // the correct app name / icon in Action Center.
app.setAppUserModelId(APP_ID) app.setAppUserModelId(APP_ID)
@@ -38,7 +55,7 @@ if (!gotLock) {
startScheduler() startScheduler()
startGamesRegistry().catch((err) => startGamesRegistry().catch((err) =>
console.error('games registry failed:', err) log.error('[index] games registry failed', err)
) )
initUpdater() initUpdater()
@@ -88,7 +105,7 @@ if (!gotLock) {
try { try {
await stopGamesRegistry() await stopGamesRegistry()
} catch (err) { } catch (err) {
console.error('[index] stopGamesRegistry threw:', err) log.error('[index] stopGamesRegistry threw', err)
} }
flushNow() flushNow()
app.exit(0) app.exit(0)

View File

@@ -7,6 +7,7 @@ import {
dialog, dialog,
shell shell
} from 'electron' } from 'electron'
import type { IpcMainEvent, IpcMainInvokeEvent } from 'electron'
import { readFileSync, writeFileSync } from 'node:fs' import { readFileSync, writeFileSync } from 'node:fs'
import { IPC } from '@shared/ipc' import { IPC } from '@shared/ipc'
import type { Exercise, GameId, Settings } from '@shared/types' import type { Exercise, GameId, Settings } from '@shared/types'
@@ -60,9 +61,57 @@ import {
validateSettingsPatch, validateSettingsPatch,
validateSnoozeMinutes validateSnoozeMinutes
} from './validate' } from './validate'
import { log } from './logger'
/**
* Враппер вокруг `ipcMain.handle`: ловит любое исключение в обработчике,
* пишет его в лог и отдаёт renderer'у обобщённую ошибку. Без этого один
* упавший хендлер молча обрывает invoke (renderer висит на await) и в проде
* не остаётся следов. Generic-сообщение наружу — не утекают внутренние детали.
*
* Констрейнт `...args: never[]` делает любую сигнатуру хендлера присваиваемой
* (контравариантность параметров), поэтому типы на call-site сохраняются.
*/
function safeHandle<
F extends (event: IpcMainInvokeEvent, ...args: never[]) => unknown
>(channel: string, fn: F): void {
ipcMain.handle(channel, async (event, ...args) => {
try {
const call = fn as unknown as (
e: IpcMainInvokeEvent,
...a: unknown[]
) => unknown
return await call(event, ...args)
} catch (err) {
log.error(`[ipc] ${channel} threw`, err)
throw new Error('ipc-failed')
}
})
}
/**
* Аналог для `ipcMain.on` (fire-and-forget). Ошибку логируем, но не
* пробрасываем — у sender'а нет канала для ответа.
*/
function safeOn<F extends (event: IpcMainEvent, ...args: never[]) => void>(
channel: string,
fn: F
): void {
ipcMain.on(channel, (event, ...args) => {
try {
const call = fn as unknown as (
e: IpcMainEvent,
...a: unknown[]
) => void
call(event, ...args)
} catch (err) {
log.error(`[ipc] ${channel} (on) threw`, err)
}
})
}
export function registerIpc(): void { export function registerIpc(): void {
ipcMain.handle(IPC.getState, () => { safeHandle(IPC.getState, () => {
// Без history (см. getStateForRenderer) и с актуальным значением // Без history (см. getStateForRenderer) и с актуальным значением
// autostart из OS — мутацию делаем по копии, не по cache. // autostart из OS — мутацию делаем по копии, не по cache.
const state = getStateForRenderer() const state = getStateForRenderer()
@@ -73,7 +122,7 @@ export function registerIpc(): void {
return state return state
}) })
ipcMain.handle(IPC.addExercise, (_e, input: unknown) => { safeHandle(IPC.addExercise, (_e, input: unknown) => {
const safe = validateExerciseInput(input) const safe = validateExerciseInput(input)
if (!safe) return null if (!safe) return null
const ex = addExercise(safe) const ex = addExercise(safe)
@@ -81,7 +130,7 @@ export function registerIpc(): void {
return ex return ex
}) })
ipcMain.handle( safeHandle(
IPC.updateExercise, IPC.updateExercise,
(_e, idRaw: unknown, patchRaw: unknown) => { (_e, idRaw: unknown, patchRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
@@ -93,7 +142,7 @@ export function registerIpc(): void {
} }
) )
ipcMain.handle(IPC.deleteExercise, (_e, idRaw: unknown) => { safeHandle(IPC.deleteExercise, (_e, idRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
if (!id) return false if (!id) return false
const ok = deleteExercise(id) const ok = deleteExercise(id)
@@ -101,7 +150,7 @@ export function registerIpc(): void {
return ok return ok
}) })
ipcMain.handle( safeHandle(
IPC.toggleExercise, IPC.toggleExercise,
(_e, idRaw: unknown, enabledRaw: unknown) => { (_e, idRaw: unknown, enabledRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
@@ -117,7 +166,7 @@ export function registerIpc(): void {
} }
) )
ipcMain.handle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => { safeHandle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
if (!id) return null if (!id) return null
const ex = markDone(id, validateActualReps(repsRaw)) const ex = markDone(id, validateActualReps(repsRaw))
@@ -126,7 +175,7 @@ export function registerIpc(): void {
return ex return ex
}) })
ipcMain.handle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => { safeHandle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
const minutes = validateSnoozeMinutes(minRaw) const minutes = validateSnoozeMinutes(minRaw)
if (!id || minutes === null) return null if (!id || minutes === null) return null
@@ -136,7 +185,7 @@ export function registerIpc(): void {
return ex return ex
}) })
ipcMain.handle(IPC.skip, (_e, idRaw: unknown) => { safeHandle(IPC.skip, (_e, idRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
if (!id) return null if (!id) return null
const ex = skip(id) const ex = skip(id)
@@ -145,7 +194,7 @@ export function registerIpc(): void {
return ex return ex
}) })
ipcMain.handle(IPC.updateSettings, (_e, patchRaw: unknown) => { safeHandle(IPC.updateSettings, (_e, patchRaw: unknown) => {
const patch = validateSettingsPatch(patchRaw) const patch = validateSettingsPatch(patchRaw)
if (!patch) return null if (!patch) return null
if (patch.startWithWindows !== undefined) { if (patch.startWithWindows !== undefined) {
@@ -165,19 +214,19 @@ export function registerIpc(): void {
return settings return settings
}) })
ipcMain.handle(IPC.pauseAll, () => { safeHandle(IPC.pauseAll, () => {
updateSettings({ globalEnabled: false }) updateSettings({ globalEnabled: false })
broadcastState() broadcastState()
refreshMenu() refreshMenu()
}) })
ipcMain.handle(IPC.resumeAll, () => { safeHandle(IPC.resumeAll, () => {
updateSettings({ globalEnabled: true }) updateSettings({ globalEnabled: true })
broadcastState() broadcastState()
forceCheck() forceCheck()
refreshMenu() refreshMenu()
}) })
ipcMain.handle(IPC.getAccentColor, () => { safeHandle(IPC.getAccentColor, () => {
try { try {
return '#' + systemPreferences.getAccentColor() return '#' + systemPreferences.getAccentColor()
} catch { } catch {
@@ -185,45 +234,45 @@ export function registerIpc(): void {
} }
}) })
ipcMain.handle(IPC.getOsTheme, () => safeHandle(IPC.getOsTheme, () =>
nativeTheme.shouldUseDarkColors ? 'dark' : 'light' nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
) )
ipcMain.handle(IPC.getAppVersion, () => app.getVersion()) safeHandle(IPC.getAppVersion, () => app.getVersion())
ipcMain.handle(IPC.getMeetingActive, () => isMeetingActiveSync()) safeHandle(IPC.getMeetingActive, () => isMeetingActiveSync())
ipcMain.handle(IPC.quit, () => app.quit()) safeHandle(IPC.quit, () => app.quit())
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow()) safeHandle(IPC.reminderClose, () => hideReminderWindow())
ipcMain.on(IPC.minimizeMain, (event) => { safeOn(IPC.minimizeMain, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize() BrowserWindow.fromWebContents(event.sender)?.minimize()
}) })
ipcMain.on(IPC.toggleMaximizeMain, (event) => { safeOn(IPC.toggleMaximizeMain, (event) => {
const win = BrowserWindow.fromWebContents(event.sender) const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return if (!win) return
if (win.isMaximized()) win.unmaximize() if (win.isMaximized()) win.unmaximize()
else win.maximize() else win.maximize()
}) })
ipcMain.handle(IPC.isMaximizedMain, (event) => { safeHandle(IPC.isMaximizedMain, (event) => {
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false
}) })
ipcMain.on(IPC.closeMain, () => { safeOn(IPC.closeMain, () => {
const main = getMainWindow() const main = getMainWindow()
if (!main) return if (!main) return
if (getState().settings.minimizeToTray) main.hide() if (getState().settings.minimizeToTray) main.hide()
else main.close() else main.close()
}) })
ipcMain.on(IPC.hideMain, () => getMainWindow()?.hide()) safeOn(IPC.hideMain, () => getMainWindow()?.hide())
// Games // Games
ipcMain.handle(IPC.gamesList, async () => listGamesStatus()) safeHandle(IPC.gamesList, async () => listGamesStatus())
ipcMain.handle(IPC.gameInstall, async (_e, id: GameId) => { safeHandle(IPC.gameInstall, async (_e, id: GameId) => {
const status = await installGame(id) const status = await installGame(id)
setGameEnabled(id, true) setGameEnabled(id, true)
await toggleGame(id, true) await toggleGame(id, true)
@@ -233,7 +282,7 @@ export function registerIpc(): void {
return status return status
}) })
ipcMain.handle(IPC.gameUninstall, async (_e, id: GameId) => { safeHandle(IPC.gameUninstall, async (_e, id: GameId) => {
const status = await uninstallGame(id) const status = await uninstallGame(id)
setGameEnabled(id, false) setGameEnabled(id, false)
const all = await listGamesStatus() const all = await listGamesStatus()
@@ -242,7 +291,7 @@ export function registerIpc(): void {
return status return status
}) })
ipcMain.handle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => { safeHandle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
setGameEnabled(id, enabled) setGameEnabled(id, enabled)
await toggleGame(id, enabled) await toggleGame(id, enabled)
const all = await listGamesStatus() const all = await listGamesStatus()
@@ -250,20 +299,20 @@ export function registerIpc(): void {
broadcastState() broadcastState()
}) })
ipcMain.handle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => { safeHandle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
// Opens Steam's library; user manually adds launch options. // Opens Steam's library; user manually adds launch options.
shell.openExternal('steam://nav/games/details/570') shell.openExternal('steam://nav/games/details/570')
}) })
// Challenges // Challenges
ipcMain.handle(IPC.addChallenge, (_e, input: unknown) => { safeHandle(IPC.addChallenge, (_e, input: unknown) => {
const safe = validateChallengeInput(input) const safe = validateChallengeInput(input)
if (!safe) return null if (!safe) return null
const c = addChallenge(safe) const c = addChallenge(safe)
broadcastState() broadcastState()
return c return c
}) })
ipcMain.handle( safeHandle(
IPC.updateChallenge, IPC.updateChallenge,
(_e, idRaw: unknown, patchRaw: unknown) => { (_e, idRaw: unknown, patchRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
@@ -274,14 +323,14 @@ export function registerIpc(): void {
return c return c
} }
) )
ipcMain.handle(IPC.deleteChallenge, (_e, idRaw: unknown) => { safeHandle(IPC.deleteChallenge, (_e, idRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
if (!id) return false if (!id) return false
const ok = deleteChallenge(id) const ok = deleteChallenge(id)
broadcastState() broadcastState()
return ok return ok
}) })
ipcMain.handle( safeHandle(
IPC.toggleChallenge, IPC.toggleChallenge,
(_e, idRaw: unknown, enabledRaw: unknown) => { (_e, idRaw: unknown, enabledRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
@@ -292,9 +341,9 @@ export function registerIpc(): void {
} }
) )
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow()) safeHandle(IPC.closeMatchSummary, () => hideReminderWindow())
ipcMain.handle( safeHandle(
IPC.markChallengeDone, IPC.markChallengeDone,
(_e, idRaw: unknown, repsRaw: unknown) => { (_e, idRaw: unknown, repsRaw: unknown) => {
const id = validateId(idRaw) const id = validateId(idRaw)
@@ -311,7 +360,7 @@ export function registerIpc(): void {
// packaged builds — a compromised renderer (XSS, malicious npm dep) could // packaged builds — a compromised renderer (XSS, malicious npm dep) could
// otherwise fabricate arbitrary match-end events at will. // otherwise fabricate arbitrary match-end events at will.
if (!app.isPackaged) { if (!app.isPackaged) {
ipcMain.handle( safeHandle(
IPC.devSimulateMatchEnd, IPC.devSimulateMatchEnd,
(_e, id: GameId, stats: Record<string, number>) => { (_e, id: GameId, stats: Record<string, number>) => {
simulateMatchEnd(id, stats) simulateMatchEnd(id, stats)
@@ -320,19 +369,19 @@ export function registerIpc(): void {
} }
// Auto-updater // Auto-updater
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus()) safeHandle(IPC.updaterStatus, () => getUpdaterStatus())
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates()) safeHandle(IPC.updaterCheck, () => checkForUpdates())
// download/install — fire-and-forget. Прогресс и завершение приходят в // download/install — fire-and-forget. Прогресс и завершение приходят в
// renderer через evtUpdaterStatus, ждать promise бессмысленно — renderer // renderer через evtUpdaterStatus, ждать promise бессмысленно — renderer
// только зря держал бы `busy=true` весь download (минуты на медленной сети). // только зря держал бы `busy=true` весь download (минуты на медленной сети).
ipcMain.on(IPC.updaterDownload, () => { safeOn(IPC.updaterDownload, () => {
void downloadUpdate() void downloadUpdate()
}) })
ipcMain.on(IPC.updaterInstall, () => quitAndInstall()) safeOn(IPC.updaterInstall, () => quitAndInstall())
// History // History
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs)) safeHandle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) => { safeHandle(IPC.clearHistory, (_e, beforeTs?: number) => {
const removed = clearHistory(beforeTs) const removed = clearHistory(beforeTs)
if (removed > 0) broadcastHistoryChanged() if (removed > 0) broadcastHistoryChanged()
return removed return removed
@@ -340,7 +389,7 @@ export function registerIpc(): void {
// Export / Import. Используем native save/open dialogs Electron'а // Export / Import. Используем native save/open dialogs Electron'а
// renderer не получает прямого доступа к ФС. // renderer не получает прямого доступа к ФС.
ipcMain.handle(IPC.exportState, async (event) => { safeHandle(IPC.exportState, async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
const stamp = new Date() const stamp = new Date()
.toISOString() .toISOString()
@@ -369,7 +418,7 @@ export function registerIpc(): void {
} }
}) })
ipcMain.handle(IPC.importState, async (event) => { safeHandle(IPC.importState, async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
const lang = getState().settings.language ?? 'ru' const lang = getState().settings.language ?? 'ru'
const result = await dialog.showOpenDialog(win!, { const result = await dialog.showOpenDialog(win!, {

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
/**
* Тесты эвристики «человек на ВКС». Мокаем `node:child_process.exec`
* (через него идёт `tasklist`), electron BrowserWindow (broadcast no-op) и
* logger. resetModules + dynamic import в каждом тесте — чтобы сбросить
* module-level кэш (`cachedActive`, `lastCheckAt`).
*/
type ExecCb = (err: Error | null, res?: { stdout: string }) => void
const h = vi.hoisted(() => ({
// Текущая реализация exec для конкретного теста.
execImpl: ((_cmd: string, _opts: unknown, cb: ExecCb) =>
cb(null, { stdout: '' })) as (
cmd: string,
opts: unknown,
cb: ExecCb
) => void,
calls: 0,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
}))
vi.mock('node:child_process', () => ({
exec: (cmd: string, opts: unknown, cb: ExecCb) => {
h.calls += 1
h.execImpl(cmd, opts, cb)
}
}))
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
vi.mock('./logger', () => ({ log: h.log }))
/** CSV-строка tasklist для заданного набора .exe. */
function csv(...procs: string[]): string {
return procs.map((p) => `"${p}","1234","Console","1","85,432 K"`).join('\r\n')
}
async function load(): Promise<typeof import('./meeting-detect')> {
return import('./meeting-detect')
}
beforeEach(() => {
vi.resetModules()
h.calls = 0
h.execImpl = (_cmd, _opts, cb) => cb(null, { stdout: '' })
h.log.info.mockClear()
h.log.warn.mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('isMeetingActive', () => {
it('детектит zoom.exe', async () => {
h.execImpl = (_c, _o, cb) => cb(null, { stdout: csv('zoom.exe') })
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(true)
})
it('детектит новые Teams (ms-teams.exe)', async () => {
h.execImpl = (_c, _o, cb) =>
cb(null, { stdout: csv('explorer.exe', 'ms-teams.exe') })
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(true)
})
it('возвращает false когда ВКС-процессов нет', async () => {
h.execImpl = (_c, _o, cb) =>
cb(null, { stdout: csv('explorer.exe', 'code.exe', 'chrome.exe') })
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(false)
})
it('кэширует результат в пределах CACHE_MS (exec вызывается один раз)', async () => {
h.execImpl = (_c, _o, cb) => cb(null, { stdout: csv('discord.exe') })
const { isMeetingActive } = await load()
await isMeetingActive()
await isMeetingActive()
expect(h.calls).toBe(1)
})
it('при падении tasklist возвращает false и логирует warn', async () => {
h.execImpl = (_c, _o, cb) => cb(new Error('ETIMEDOUT'))
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(false)
expect(h.log.warn).toHaveBeenCalled()
})
it('isMeetingActiveSync отражает последний известный результат', async () => {
h.execImpl = (_c, _o, cb) => cb(null, { stdout: csv('webex.exe') })
const mod = await load()
expect(mod.isMeetingActiveSync()).toBe(false) // до первого запроса
await mod.isMeetingActive()
expect(mod.isMeetingActiveSync()).toBe(true)
})
it('на не-Windows возвращает false без вызова tasklist', async () => {
const original = process.platform
Object.defineProperty(process, 'platform', { value: 'linux' })
try {
const { isMeetingActive } = await load()
expect(await isMeetingActive()).toBe(false)
expect(h.calls).toBe(0)
} finally {
Object.defineProperty(process, 'platform', { value: original })
}
})
})

View File

@@ -64,7 +64,12 @@ export async function isMeetingActive(): Promise<boolean> {
// CSV без заголовков (/NH), скрытое окно. // CSV без заголовков (/NH), скрытое окно.
const { stdout } = await execAsync('tasklist /FO CSV /NH', { const { stdout } = await execAsync('tasklist /FO CSV /NH', {
windowsHide: true, windowsHide: true,
maxBuffer: 4 * 1024 * 1024 // tasklist бывает большой maxBuffer: 4 * 1024 * 1024, // tasklist бывает большой
// Если tasklist подвис (повреждённый WMI, загруженная система) — exec
// сам прибьёт процесс и уйдёт в catch. Без таймаута зависшие child
// накапливались бы при каждом refresh.
timeout: 4000,
killSignal: 'SIGKILL'
}) })
const lower = stdout.toLowerCase() const lower = stdout.toLowerCase()
for (const proc of MEETING_PROCESSES) { for (const proc of MEETING_PROCESSES) {

167
src/main/scheduler.test.ts Normal file
View File

@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type {
Exercise,
HistoryEntry,
QuietHours,
Settings
} from '@shared/types'
import { DEFAULT_SETTINGS } from '@shared/types'
/**
* Тесты gating-логики scheduler'а. Дёргаем публичный `forceCheck()` (он
* сбрасывает lastCheckAt и прогоняет tick → checkDueExercises) и проверяем,
* вызвался ли `fireReminder`. Стор/нотификации/meeting/adaptive замоканы.
*/
const h = vi.hoisted(() => ({
settings: null as Settings | null,
exercises: [] as Exercise[],
history: [] as HistoryEntry[],
meetingActive: false,
fireReminder: vi.fn(),
updateExercise: vi.fn(),
broadcastState: vi.fn(),
refreshMeetingState: vi.fn(),
adjustNextFireAt: vi.fn((_ex: Exercise, candidate: number) => candidate)
}))
vi.mock('electron', () => ({
powerMonitor: { on: vi.fn() },
BrowserWindow: { getAllWindows: () => [] }
}))
vi.mock('./store', () => ({
getSettings: () => h.settings,
getExercises: () => h.exercises,
getHistory: () => h.history,
updateExercise: (id: string, patch: Partial<Exercise>) => {
h.updateExercise(id, patch)
const ex = h.exercises.find((e) => e.id === id)
return ex ? { ...ex, ...patch } : undefined
}
}))
vi.mock('./notifications', () => ({ fireReminder: h.fireReminder }))
vi.mock('./state-actions', () => ({ broadcastState: h.broadcastState }))
vi.mock('./meeting-detect', () => ({
isMeetingActiveSync: () => h.meetingActive,
refreshMeetingState: h.refreshMeetingState
}))
vi.mock('./adaptive', () => ({ adjustNextFireAt: h.adjustNextFireAt }))
function makeExercise(over: Partial<Exercise> = {}): Exercise {
return {
id: 'ex1',
name: 'Приседания',
reps: 10,
icon: 'Activity',
intervalMinutes: 30,
enabled: true,
nextFireAt: Date.now() - 1000, // due by default
...over
}
}
/** Тихие часы, гарантированно покрывающие текущий момент (без wrap). */
function quietWindowAroundNow(): QuietHours {
const now = new Date()
const cur = now.getHours() * 60 + now.getMinutes()
const fromMin = Math.max(0, cur - 60)
const toMin = Math.min(1439, cur + 60)
const fmt = (m: number): string =>
`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(
2,
'0'
)}`
return { enabled: true, from: fmt(fromMin), to: fmt(toMin), days: [] }
}
async function loadScheduler(): Promise<typeof import('./scheduler')> {
return import('./scheduler')
}
beforeEach(() => {
vi.resetModules()
h.settings = { ...DEFAULT_SETTINGS }
h.exercises = []
h.history = []
h.meetingActive = false
h.fireReminder.mockClear()
h.updateExercise.mockClear()
h.broadcastState.mockClear()
h.refreshMeetingState.mockClear()
h.adjustNextFireAt.mockClear()
})
describe('checkDueExercises gating', () => {
it('не fire-ит когда globalEnabled=false', async () => {
h.settings = { ...DEFAULT_SETTINGS, globalEnabled: false }
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('не fire-ит внутри тихих часов', async () => {
h.settings = { ...DEFAULT_SETTINGS, quietHours: quietWindowAroundNow() }
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('не fire-ит когда активна ВКС (meetingAutoPause)', async () => {
h.settings = { ...DEFAULT_SETTINGS, meetingAutoPause: true }
h.meetingActive = true
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.refreshMeetingState).toHaveBeenCalled()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('fire-ит готовое к срабатыванию упражнение и шлёт broadcastState', async () => {
h.exercises = [makeExercise()]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).toHaveBeenCalledTimes(1)
expect(h.broadcastState).toHaveBeenCalled()
})
it('пропускает выключенные упражнения', async () => {
h.exercises = [makeExercise({ enabled: false })]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('не fire-ит упражнение, чьё время ещё не пришло', async () => {
h.exercises = [makeExercise({ nextFireAt: Date.now() + 60_000 })]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
})
it('soft-cap: при закрытой dailyGoal переносит fire, но не показывает', async () => {
h.exercises = [makeExercise({ dailyGoal: 20 })]
h.history = [
{
ts: Date.now(),
exerciseId: 'ex1',
action: 'done',
actualReps: 25
}
]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.fireReminder).not.toHaveBeenCalled()
// nextFireAt перенесён (на завтра) — updateExercise вызван.
expect(h.updateExercise).toHaveBeenCalled()
})
it('adaptive: применяет adjustNextFireAt к кандидату', async () => {
h.exercises = [makeExercise({ adaptive: true })]
const { forceCheck } = await loadScheduler()
forceCheck()
expect(h.adjustNextFireAt).toHaveBeenCalled()
expect(h.fireReminder).toHaveBeenCalledTimes(1)
})
})

198
src/main/store.test.ts Normal file
View File

@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
mkdtempSync,
rmSync,
writeFileSync,
existsSync,
readdirSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
/**
* Тесты persistence-слоя. Мокаем electron.app.getPath на временную директорию
* (новую на каждый тест) и logger. resetModules + dynamic import сбрасывают
* module-level `cache`/`storePath`, чтобы тесты не текли друг в друга.
*/
const h = vi.hoisted(() => ({
userData: '',
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
}))
vi.mock('electron', () => ({
app: {
getPath: () => h.userData,
getVersion: () => '0.0.0-test'
}
}))
vi.mock('./logger', () => ({ log: h.log }))
async function load(): Promise<typeof import('./store')> {
return import('./store')
}
function statePath(): string {
return join(h.userData, 'app-state.json')
}
function corruptFiles(): string[] {
return readdirSync(h.userData).filter((f) => f.includes('.corrupt-'))
}
beforeEach(() => {
vi.resetModules()
h.userData = mkdtempSync(join(tmpdir(), 'laude-store-'))
h.log.error.mockClear()
h.log.warn.mockClear()
})
afterEach(() => {
try {
rmSync(h.userData, { recursive: true, force: true })
} catch {
/* ignore */
}
})
describe('store · cold start', () => {
it('создаёт app-state.json с примерами упражнений на первом запуске', async () => {
const { getState } = await load()
const state = getState()
expect(state.exercises.length).toBeGreaterThan(0)
expect(existsSync(statePath())).toBe(true)
})
})
describe('store · corrupt file quarantine', () => {
it('битый JSON уносится в .corrupt-* и стартует чистый state', async () => {
writeFileSync(statePath(), '{ this is : not json', 'utf-8')
const { getState } = await load()
const state = getState()
expect(state.exercises.length).toBeGreaterThan(0) // initial
expect(corruptFiles().length).toBe(1)
expect(h.log.error).toHaveBeenCalled()
})
it('валидный JSON, но не объект (массив) — тоже карантин', async () => {
writeFileSync(statePath(), '[1,2,3]', 'utf-8')
const { getState } = await load()
expect(getState().exercises.length).toBeGreaterThan(0)
expect(corruptFiles().length).toBe(1)
})
})
describe('store · coerce / migrations', () => {
it('подставляет дефолтные settings когда их нет в файле', async () => {
writeFileSync(
statePath(),
JSON.stringify({ exercises: [], challenges: [], history: [] }),
'utf-8'
)
const { getSettings } = await load()
const s = getSettings()
expect(s.globalEnabled).toBeDefined()
expect(s.notificationMode).toBeDefined()
expect(s.snoozeMinutes).toBeGreaterThan(0)
})
it('файл без __schemaVersion грузится без потери данных', async () => {
const ex = {
id: 'x1',
name: 'Тест',
reps: 10,
icon: 'Dumbbell',
intervalMinutes: 30,
enabled: true,
nextFireAt: Date.now() + 1000
}
writeFileSync(
statePath(),
JSON.stringify({ exercises: [ex], challenges: [], history: [] }),
'utf-8'
)
const { getExercises } = await load()
const list = getExercises()
expect(list).toHaveLength(1)
expect(list[0].name).toBe('Тест')
})
})
describe('store · history cap', () => {
it('обрезает историю когда превышен HISTORY_MAX', async () => {
const ex = {
id: 'x1',
name: 'Приседания',
reps: 10,
icon: 'Activity',
intervalMinutes: 30,
enabled: true,
nextFireAt: Date.now()
}
const big = Array.from({ length: 10_005 }, (_unused, i) => ({
ts: i,
exerciseId: 'x1',
action: 'done' as const,
reps: 10
}))
writeFileSync(
statePath(),
JSON.stringify({ exercises: [ex], challenges: [], history: big }),
'utf-8'
)
const { markDone, getHistory } = await load()
markDone('x1')
// 10005 + 1 = 10006 > 10000 → slice(-9000)
expect(getHistory().length).toBe(9000)
})
})
describe('store · clearHistory', () => {
it('удаляет записи старше границы и возвращает количество', async () => {
const ex = {
id: 'x1',
name: 'A',
reps: 5,
icon: 'Activity',
intervalMinutes: 10,
enabled: true,
nextFireAt: Date.now()
}
const history = [
{ ts: 100, exerciseId: 'x1', action: 'done' as const },
{ ts: 200, exerciseId: 'x1', action: 'done' as const },
{ ts: 300, exerciseId: 'x1', action: 'done' as const }
]
writeFileSync(
statePath(),
JSON.stringify({ exercises: [ex], challenges: [], history }),
'utf-8'
)
const { clearHistory, getHistory } = await load()
const removed = clearHistory(250)
expect(removed).toBe(2)
expect(getHistory().map((e) => e.ts)).toEqual([300])
})
it('отказывается чистить без явной границы (защита от полного wipe)', async () => {
const { clearHistory } = await load()
expect(clearHistory()).toBe(0)
})
})
describe('store · export / import', () => {
it('export даёт валидный JSON со схемой; import парсит его обратно', async () => {
const { exportState, importState } = await load()
const json = exportState()
const parsed = JSON.parse(json)
expect(typeof parsed.__schemaVersion).toBe('number')
expect(parsed.__schemaVersion).toBeGreaterThanOrEqual(1)
expect(importState(json)).toBe(true)
})
it('import отклоняет мусор', async () => {
const { importState } = await load()
expect(importState('not json at all')).toBe(false)
expect(importState('42')).toBe(false)
})
})

View File

@@ -303,11 +303,11 @@ function atomicWriteSync(path: string, contents: string): void {
} }
const delay = WRITE_RETRY_DELAYS[i] const delay = WRITE_RETRY_DELAYS[i]
if (delay === undefined) break if (delay === undefined) break
// Event-loop остановлен, async sleep не вернётся — приходится spin. // Event-loop остановлен (exit-path), async sleep не вернётся — нужен
const until = Date.now() + delay // блокирующий sync sleep. Atomics.wait на «свежем» буфере всегда уходит
while (Date.now() < until) { // в таймаут (значение совпадает с ожидаемым 0), т.е. честно спит delay мс
/* spin */ // без сжигания CPU — в отличие от старого busy-loop.
} Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
} }
} }
log.error('[store] atomic sync write failed after retries', lastErr) log.error('[store] atomic sync write failed after retries', lastErr)

View File

@@ -127,7 +127,15 @@ async function bootCheckWithRetry(): Promise<void> {
return // success return // success
} }
const delay = BOOT_RETRY_DELAYS[attempt] const delay = BOOT_RETRY_DELAYS[attempt]
if (delay === undefined) return // exhausted retries if (delay === undefined) {
// Исчерпали ретраи — раньше сдавались молча. Логируем, чтобы при
// диагностике было видно «boot-check так и не достучался». Следующая
// попытка — на ближайшем hourly-тике.
log.warn(
'[updater] boot check exhausted retries — will retry on hourly tick'
)
return
}
await new Promise((r) => setTimeout(r, delay)) await new Promise((r) => setTimeout(r, delay))
} }
} }

View File

@@ -5,6 +5,7 @@ import { Sidebar } from './components/Sidebar'
import { Titlebar } from './components/Titlebar' import { Titlebar } from './components/Titlebar'
import { ErrorBoundary } from './components/ErrorBoundary' import { ErrorBoundary } from './components/ErrorBoundary'
import { WhatsNewModal } from './components/WhatsNewModal' import { WhatsNewModal } from './components/WhatsNewModal'
import { Skeleton } from './components/ui/Skeleton'
import { unseenVersions } from '@shared/release-notes' import { unseenVersions } from '@shared/release-notes'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import Exercises from './pages/Exercises' import Exercises from './pages/Exercises'
@@ -100,8 +101,23 @@ export default function App(): JSX.Element {
<RoutedPages onNav={() => setMobileNavOpen(false)} /> <RoutedPages onNav={() => setMobileNavOpen(false)} />
</ErrorBoundary> </ErrorBoundary>
) : ( ) : (
// Neutral placeholder — settings (and lang) aren't loaded yet. // Skeleton на время гидрации — settings (и язык) ещё не
<div className="p-8 text-text/45" /> // загружены, текст показывать рано, но пустота выглядит как
// зависание. Каркас задаёт ожидание «сейчас появится контент».
<div
className="p-6 sm:p-8 space-y-5 max-w-3xl"
role="status"
aria-label="Loading"
>
<Skeleton className="h-9 w-48" />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-40" />
<Skeleton className="h-28" />
</div>
)} )}
</main> </main>
</div> </div>

View File

@@ -7,6 +7,7 @@ import {
type AchievementProgress type AchievementProgress
} from '../lib/achievements' } from '../lib/achievements'
import { useT } from '../i18n' import { useT } from '../i18n'
import { useAnnounce } from '../lib/useAnnounce'
const CELEBRATED_KEY = 'laude:celebratedAchievements' const CELEBRATED_KEY = 'laude:celebratedAchievements'
@@ -48,6 +49,7 @@ type Props = {
*/ */
export function AchievementsCard({ history, exercises }: Props): JSX.Element { export function AchievementsCard({ history, exercises }: Props): JSX.Element {
const { t } = useT() const { t } = useT()
const announce = useAnnounce()
const achievements = useMemo( const achievements = useMemo(
() => computeAchievements(history, exercises), () => computeAchievements(history, exercises),
@@ -73,11 +75,19 @@ export function AchievementsCard({ history, exercises }: Props): JSX.Element {
if (fresh.size > 0) { if (fresh.size > 0) {
setFreshlyUnlocked(fresh) setFreshlyUnlocked(fresh)
saveCelebrated(celebrated) saveCelebrated(celebrated)
// Озвучиваем разблокировку для screen-reader'ов — pulse-анимацию они
// не видят.
for (const a of achievements) {
if (fresh.has(a.def.id)) {
announce(t('achievements.announce', { title: t(a.def.titleKey) }))
}
}
// Снимаем «свежесть» через 5 сек чтобы pulse не крутился вечно. // Снимаем «свежесть» через 5 сек чтобы pulse не крутился вечно.
const t = setTimeout(() => setFreshlyUnlocked(new Set()), 5_000) const timer = setTimeout(() => setFreshlyUnlocked(new Set()), 5_000)
return () => clearTimeout(t) return () => clearTimeout(timer)
} }
return undefined return undefined
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [achievements]) }, [achievements])
const unlocked = achievements.filter((a) => a.unlocked) const unlocked = achievements.filter((a) => a.unlocked)

View File

@@ -1,11 +1,12 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Check, MoreHorizontal, Brain, CheckCircle2 } from 'lucide-react' import { Check, MoreHorizontal, Brain, CheckCircle2 } from 'lucide-react'
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { Exercise, Tick } from '@shared/types' import type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon' import { Icon } from '../lib/icon'
import { formatCountdown } from '../lib/format' import { formatCountdown } from '../lib/format'
import { Switch } from './ui/Switch' import { Switch } from './ui/Switch'
import { useT } from '../i18n' import { useT } from '../i18n'
import { useAnnounce } from '../lib/useAnnounce'
type Props = { type Props = {
exercise: Exercise exercise: Exercise
@@ -43,21 +44,64 @@ export function ExerciseCard({
// Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем. // Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем.
const isDue = ms <= 0 && exercise.enabled && !goalReached const isDue = ms <= 0 && exercise.enabled && !goalReached
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// При открытии меню переводим фокус на первый пункт — клавиатурный
// пользователь сразу внутри, без слепого Tab'а.
useEffect(() => {
if (!menuOpen) return
menuRef.current
?.querySelector<HTMLButtonElement>('[role="menuitem"]')
?.focus()
}, [menuOpen])
// Esc закрывает и возвращает фокус на триггер; стрелки/Home/End — навигация.
const onMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
const items = Array.from(
menuRef.current?.querySelectorAll<HTMLButtonElement>(
'[role="menuitem"]'
) ?? []
)
if (items.length === 0) return
const idx = items.indexOf(document.activeElement as HTMLButtonElement)
if (e.key === 'Escape') {
e.preventDefault()
setMenuOpen(false)
triggerRef.current?.focus()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
items[(idx + 1) % items.length]?.focus()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
items[(idx - 1 + items.length) % items.length]?.focus()
} else if (e.key === 'Home') {
e.preventDefault()
items[0]?.focus()
} else if (e.key === 'End') {
e.preventDefault()
items[items.length - 1]?.focus()
}
}
// Дедуп rapid double-click на «Готово». Между кликом и обновлением // Дедуп rapid double-click на «Готово». Между кликом и обновлением
// nextFireAt (через broadcastState) есть окно ~1 сек, в которое можно // nextFireAt (через broadcastState) есть окно ~1 сек, в которое можно
// вызвать markDone повторно и записать лишний entry в историю. // вызвать markDone повторно и записать лишний entry в историю.
const markDoneInFlightRef = useRef(false) const markDoneInFlightRef = useRef(false)
const { t, lang } = useT()
const announce = useAnnounce()
const handleMarkDone = (): void => { const handleMarkDone = (): void => {
if (markDoneInFlightRef.current) return if (markDoneInFlightRef.current) return
markDoneInFlightRef.current = true markDoneInFlightRef.current = true
onMarkDone() onMarkDone()
// Озвучиваем для screen-reader'ов — кнопка после засчёта исчезает,
// визуальный feedback незрячему недоступен.
announce(`${t('btn.done')}: ${exercise.name}`)
// К моменту окончания таймаута isDue уже false (после store-tick), кнопка // К моменту окончания таймаута isDue уже false (после store-tick), кнопка
// не рендерится — флаг чистим на всякий случай для будущих кейсов. // не рендерится — флаг чистим на всякий случай для будущих кейсов.
setTimeout(() => { setTimeout(() => {
markDoneInFlightRef.current = false markDoneInFlightRef.current = false
}, 1000) }, 1000)
} }
const { t, lang } = useT()
// Ring math // Ring math
const R = 22 const R = 22
@@ -124,9 +168,12 @@ export function ExerciseCard({
</h3> </h3>
<div className="relative"> <div className="relative">
<button <button
ref={triggerRef}
onClick={() => setMenuOpen((v) => !v)} onClick={() => setMenuOpen((v) => !v)}
className="w-7 h-7 grid place-items-center rounded-full text-text/45 hover:bg-surface-2 active:scale-90 transition-all" className="w-7 h-7 grid place-items-center rounded-full text-text/45 hover:bg-surface-2 active:scale-90 transition-all"
aria-label={t('titlebar.menu_aria')} aria-label={t('titlebar.menu_aria')}
aria-haspopup="menu"
aria-expanded={menuOpen}
> >
<MoreHorizontal size={16} /> <MoreHorizontal size={16} />
</button> </button>
@@ -136,8 +183,15 @@ export function ExerciseCard({
className="fixed inset-0 z-10" className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)} onClick={() => setMenuOpen(false)}
/> />
<div className="absolute right-0 top-8 z-20 min-w-[140px] bg-surface rounded-xl shadow-sheet ring-0.5 ring-hairline/30 py-1 overflow-hidden"> <div
ref={menuRef}
role="menu"
aria-label={exercise.name}
onKeyDown={onMenuKeyDown}
className="absolute right-0 top-8 z-20 min-w-[140px] bg-surface rounded-xl shadow-sheet ring-0.5 ring-hairline/30 py-1 overflow-hidden"
>
<button <button
role="menuitem"
onClick={() => { onClick={() => {
setMenuOpen(false) setMenuOpen(false)
onEdit() onEdit()
@@ -147,6 +201,7 @@ export function ExerciseCard({
{t('btn.edit')} {t('btn.edit')}
</button> </button>
<button <button
role="menuitem"
onClick={() => { onClick={() => {
setMenuOpen(false) setMenuOpen(false)
onDelete() onDelete()

View File

@@ -0,0 +1,21 @@
type Props = {
className?: string
}
/**
* Placeholder-блок на время загрузки данных. `aria-hidden` — это чисто
* визуальный шум, screen-reader'у он не нужен (рядом обычно есть role="status"
* или контент появится сам). Пульсация через Tailwind `animate-pulse`
* (гасится при prefers-reduced-motion — см. globals.css).
*/
export function Skeleton({ className = '' }: Props): JSX.Element {
return (
<div
aria-hidden="true"
className={[
'animate-pulse rounded-2xl bg-surface-2/70 dark:bg-surface-2',
className
].join(' ')}
/>
)
}

View File

@@ -0,0 +1,29 @@
import { Loader2 } from 'lucide-react'
type Props = {
size?: number
className?: string
/** Подпись для screen-reader'ов. По умолчанию нейтральное «Loading». */
label?: string
}
/**
* Индикатор асинхронной операции. `role="status"` + aria-label делают его
* слышимым для screen-reader'ов. Вращение через Tailwind `animate-spin`
* (замедляется, но не выключается при prefers-reduced-motion — см. globals.css).
*/
export function Spinner({
size = 16,
className = '',
label = 'Loading'
}: Props): JSX.Element {
return (
<Loader2
size={size}
strokeWidth={2.4}
role="status"
aria-label={label}
className={['animate-spin', className].join(' ')}
/>
)
}

View File

@@ -250,6 +250,7 @@ export const ru: Dict = {
'achievements.title': 'Достижения', 'achievements.title': 'Достижения',
'achievements.unlocked_of': '{n} из {total}', 'achievements.unlocked_of': '{n} из {total}',
'achievements.progress': 'осталось {n}', 'achievements.progress': 'осталось {n}',
'achievements.announce': 'Достижение получено: {title}',
'achievement.reps.desc': 'Сделай {target} повторений всего', 'achievement.reps.desc': 'Сделай {target} повторений всего',
'achievement.reps_100.title': 'Сотня', 'achievement.reps_100.title': 'Сотня',
'achievement.reps_500.title': 'Пятьсот', 'achievement.reps_500.title': 'Пятьсот',
@@ -581,6 +582,7 @@ export const en: Dict = {
'achievements.title': 'Achievements', 'achievements.title': 'Achievements',
'achievements.unlocked_of': '{n} of {total}', 'achievements.unlocked_of': '{n} of {total}',
'achievements.progress': '{n} to go', 'achievements.progress': '{n} to go',
'achievements.announce': 'Achievement unlocked: {title}',
'achievement.reps.desc': '{target} reps total', 'achievement.reps.desc': '{target} reps total',
'achievement.reps_100.title': 'Century', 'achievement.reps_100.title': 'Century',
'achievement.reps_500.title': 'Five hundred', 'achievement.reps_500.title': 'Five hundred',

View File

@@ -0,0 +1,49 @@
import { useCallback } from 'react'
/**
* Озвучивание событий для screen-reader'ов через единый скрытый
* aria-live регион. Анимации/тосты видят зрячие; незрячим нужно явное
* сообщение — иначе «достижение разблокировано» или «упражнение засчитано»
* проходят молча.
*
* Регион — module-level singleton (один на окно), создаётся лениво и живёт
* до закрытия окна. Так не нужен провайдер в дереве компонентов.
*/
let region: HTMLElement | null = null
function ensureRegion(): HTMLElement {
if (region && document.body.contains(region)) return region
const el = document.createElement('div')
el.setAttribute('aria-live', 'polite')
el.setAttribute('aria-atomic', 'true')
el.setAttribute('role', 'status')
// Визуально скрыто, но доступно для screen-reader'ов (sr-only pattern).
Object.assign(el.style, {
position: 'absolute',
width: '1px',
height: '1px',
margin: '-1px',
padding: '0',
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
whiteSpace: 'nowrap',
border: '0'
})
document.body.appendChild(el)
region = el
return el
}
export function useAnnounce(): (message: string) => void {
return useCallback((message: string) => {
if (!message) return
const el = ensureRegion()
// Сброс перед записью: screen-reader игнорирует повторную установку
// идентичного текста. Разносим очистку и значение по разным кадрам,
// чтобы изменение точно зарегистрировалось.
el.textContent = ''
requestAnimationFrame(() => {
el.textContent = message
})
}, [])
}

View File

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

View File

@@ -139,8 +139,13 @@ export default function ChallengesPage(): JSX.Element {
</> </>
) : ( ) : (
<Card> <Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium"> <div className="px-5 py-12 flex flex-col items-center text-center">
{t('challenges.empty')} <div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
<Gamepad2 size={24} strokeWidth={2.4} />
</div>
<div className="text-text/65 text-[15px] font-medium max-w-xs leading-snug">
{t('challenges.empty')}
</div>
</div> </div>
</Card> </Card>
)} )}

View File

@@ -93,8 +93,13 @@ export default function Exercises(): JSX.Element {
{exercises.length === 0 && ( {exercises.length === 0 && (
<Card> <Card>
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium"> <div className="px-5 py-12 flex flex-col items-center text-center">
{t('exercises.empty')} <div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
<Plus size={24} strokeWidth={2.5} />
</div>
<div className="text-text/65 text-[15px] font-medium max-w-xs leading-snug">
{t('exercises.empty')}
</div>
</div> </div>
</Card> </Card>
)} )}

View File

@@ -11,6 +11,7 @@ import {
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Switch } from '../components/ui/Switch' import { Switch } from '../components/ui/Switch'
import { Spinner } from '../components/ui/Spinner'
import { Card, SectionHeader } from '../components/ui/Card' import { Card, SectionHeader } from '../components/ui/Card'
import type { GameId, GameStatus } from '@shared/types' import type { GameId, GameStatus } from '@shared/types'
import { useT } from '../i18n' import { useT } from '../i18n'
@@ -104,7 +105,11 @@ export default function GamesPage(): JSX.Element {
))} ))}
{games.length === 0 && ( {games.length === 0 && (
<Card> <Card>
<div className="px-5 py-12 text-center text-text/55 text-[14px]"> <div
className="px-5 py-12 flex flex-col items-center gap-3 text-center text-text/55 text-[14px]"
role="status"
>
<Spinner size={22} className="text-accent" label={t('games.scanning')} />
{t('games.scanning')} {t('games.scanning')}
</div> </div>
</Card> </Card>
@@ -201,7 +206,12 @@ function GameCard({
<div className="flex items-center flex-wrap gap-2 mt-4"> <div className="flex items-center flex-wrap gap-2 mt-4">
{game.installed && !game.integrationActive && ( {game.installed && !game.integrationActive && (
<Button onClick={onInstall} disabled={busy} size="sm"> <Button onClick={onInstall} disabled={busy} size="sm">
<Download size={14} strokeWidth={2.5} /> {t('btn.connect')} {busy ? (
<Spinner size={14} />
) : (
<Download size={14} strokeWidth={2.5} />
)}{' '}
{t('btn.connect')}
</Button> </Button>
)} )}
{game.integrationActive && ( {game.integrationActive && (
@@ -211,7 +221,8 @@ function GameCard({
disabled={busy} disabled={busy}
size="sm" size="sm"
> >
<Trash2 size={14} strokeWidth={2.5} /> {t('btn.disconnect')} {busy ? <Spinner size={14} /> : <Trash2 size={14} strokeWidth={2.5} />}{' '}
{t('btn.disconnect')}
</Button> </Button>
)} )}
{!game.installed && ( {!game.installed && (

View File

@@ -5,6 +5,8 @@ import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard' import { UpdaterCard } from '../components/UpdaterCard'
import { WhatsNewModal } from '../components/WhatsNewModal' import { WhatsNewModal } from '../components/WhatsNewModal'
import { ConfirmModal } from '../components/ui/ConfirmModal' import { ConfirmModal } from '../components/ui/ConfirmModal'
import { Skeleton } from '../components/ui/Skeleton'
import { Spinner } from '../components/ui/Spinner'
import { RELEASE_NOTES } from '@shared/release-notes' import { RELEASE_NOTES } from '@shared/release-notes'
import { useT } from '../i18n' import { useT } from '../i18n'
import type { import type {
@@ -19,7 +21,18 @@ export default function SettingsPage(): JSX.Element {
const settings = useAppStore((s) => s.state?.settings) const settings = useAppStore((s) => s.state?.settings)
const { t } = useT() const { t } = useT()
if (!settings) if (!settings)
return <div className="p-8 text-text/45">{t('settings.loading')}</div> return (
<div
className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12 space-y-5"
role="status"
aria-label={t('settings.loading')}
>
<Skeleton className="h-10 w-56" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-24" />
</div>
)
const patch = (p: Partial<SettingsType>): void => { const patch = (p: Partial<SettingsType>): void => {
window.api.updateSettings(p) window.api.updateSettings(p)
@@ -244,7 +257,9 @@ function AboutCard(): JSX.Element {
function DataCard(): JSX.Element { function DataCard(): JSX.Element {
const { t } = useT() const { t } = useT()
const [busy, setBusy] = useState(false) // Какая операция сейчас идёт — чтобы крутить спиннер на нужной кнопке,
// а не на обеих сразу.
const [busy, setBusy] = useState<'export' | 'import' | null>(null)
const [toast, setToast] = useState<string | null>(null) const [toast, setToast] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false)
@@ -256,7 +271,7 @@ function DataCard(): JSX.Element {
}, [toast]) }, [toast])
async function onExport(): Promise<void> { async function onExport(): Promise<void> {
setBusy(true) setBusy('export')
try { try {
const r = await window.api.exportState() const r = await window.api.exportState()
if (r.ok && r.path) { if (r.ok && r.path) {
@@ -266,13 +281,13 @@ function DataCard(): JSX.Element {
setToast(t('settings.data.export.err')) setToast(t('settings.data.export.err'))
} }
} finally { } finally {
setBusy(false) setBusy(null)
} }
} }
async function performImport(): Promise<void> { async function performImport(): Promise<void> {
setConfirmOpen(false) setConfirmOpen(false)
setBusy(true) setBusy('import')
try { try {
const r = await window.api.importState() const r = await window.api.importState()
if (r.ok) setToast(t('settings.data.import.ok')) if (r.ok) setToast(t('settings.data.import.ok'))
@@ -281,7 +296,7 @@ function DataCard(): JSX.Element {
setToast(t('settings.data.import.err')) setToast(t('settings.data.import.err'))
} }
} finally { } finally {
setBusy(false) setBusy(null)
} }
} }
@@ -298,9 +313,10 @@ function DataCard(): JSX.Element {
</div> </div>
<button <button
onClick={onExport} onClick={onExport}
disabled={busy} disabled={busy !== null}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50" className="inline-flex items-center gap-2 h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
> >
{busy === 'export' && <Spinner size={14} />}
{t('settings.data.export.btn')} {t('settings.data.export.btn')}
</button> </button>
</Row> </Row>
@@ -315,9 +331,10 @@ function DataCard(): JSX.Element {
</div> </div>
<button <button
onClick={() => setConfirmOpen(true)} onClick={() => setConfirmOpen(true)}
disabled={busy} disabled={busy !== null}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50" className="inline-flex items-center gap-2 h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
> >
{busy === 'import' && <Spinner size={14} />}
{t('settings.data.import.btn')} {t('settings.data.import.btn')}
</button> </button>
</Row> </Row>

View File

@@ -230,3 +230,25 @@ body {
.dark .text-tertiary { .dark .text-tertiary {
color: rgb(var(--text-tertiary) / 0.3); color: rgb(var(--text-tertiary) / 0.3);
} }
/* ===== Reduced motion =====
framer-motion закрывает свои анимации через MotionConfig reducedMotion="user"
(см. main.tsx). Этот блок гасит CSS-анимации/переходы, которые framer не
контролирует (Tailwind animate-spin/-pulse, нативные transition). Спиннеры
намеренно НЕ обнуляем полностью — индикатор загрузки должен крутиться, иначе
пропадает смысл; но замедляем, чтобы не мелькал. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
/* Спиннеры — единственное исключение: оставляем вращение, но медленнее. */
.animate-spin {
animation-duration: 1.2s !important;
animation-iteration-count: infinite !important;
}
}