Compare commits
5 Commits
v0.5.2
...
36085f225f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36085f225f | ||
|
|
03ab4eebf5 | ||
|
|
a64f03b3cc | ||
|
|
e96ca06587 | ||
|
|
2503b27d42 |
120
CHANGELOG.md
120
CHANGELOG.md
@@ -6,10 +6,122 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.3] — 2026-05-19
|
||||
|
||||
Полировка кастомного тайтлбара и размера окна.
|
||||
|
||||
### Added
|
||||
|
||||
- Prettier + ESLint конфиги, скрипты `npm run format` / `npm run lint`.
|
||||
- `.editorconfig` для единообразного оформления между редакторами.
|
||||
- **Maximize/Restore.** Средняя кнопка тайтлбара (иконка квадрата)
|
||||
раньше была «спрятать в трей» — выглядела как нативная Windows
|
||||
maximize и сбивала с толку. Теперь это настоящий toggle на
|
||||
весь экран: иконка свапается `Square` ↔ `Copy` в зависимости
|
||||
от состояния, aria-label локализован.
|
||||
- **Double-click по тайтлбару** тоже toggleMaximize — стандартный
|
||||
Windows-жест.
|
||||
- **CLAUDE.md** в корне — контекст проекта для будущих сессий
|
||||
Claude Code (стек, архитектура, команды, тех. долг).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Drag-зона тайтлбара.** Окно не двигалось, если хватать его
|
||||
рядом с кнопками свернуть/закрыть. Класс `titlebar-nodrag` стоял
|
||||
на обёртке кластера с `flex-1 basis-0`, поэтому пустое место
|
||||
слева от иконок тоже было no-drag. Перенесли `no-drag` на сами
|
||||
кнопки — теперь тащить можно отовсюду, кроме самих квадратиков.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Минимальный размер окна** 900×600 → 1100×700. Гарантирует
|
||||
срабатывание Tailwind `lg:` (4 hero-stat в один ряд, heatmap
|
||||
и сетка упражнений помещаются без горизонтального скролла).
|
||||
|
||||
## [0.5.2] — 2026-05-19
|
||||
|
||||
Большая внутренняя итерация: тройной независимый аудит (~220 находок),
|
||||
закрыты топ-приоритеты. Тестов 53, ESLint и Prettier чистые, typecheck OK.
|
||||
|
||||
### Added
|
||||
|
||||
- **Prettier + ESLint + EditorConfig.** Конфиги, скрипты
|
||||
`npm run format` / `format:check` / `lint`, CI-готовые правила. Вся
|
||||
`src/` единообразно отформатирована.
|
||||
- **Error Boundary** на двух уровнях: вокруг всего App и вокруг
|
||||
роутов. Крах одной страницы (например, malformed history в
|
||||
HistoryHeatmap) больше не блэнкит окно — показывается локализованный
|
||||
fallback с кнопкой «Попробовать снова». Stack trace только в dev.
|
||||
- **IPC validation layer** (`src/main/validate.ts`) — hand-rolled
|
||||
схемы для всех renderer-supplied payload (intervalMinutes ∈ [1,1440],
|
||||
reps ∈ [1,9999], multiplier ∈ [0,1000], string-cap 200 chars,
|
||||
enum-валидация для theme/lang/notify-mode/stat, regex для HH:MM,
|
||||
дедупликация quietHours.days). Compromised renderer больше не может
|
||||
засунуть `reps: NaN` или `intervalMinutes: -1` в стор.
|
||||
- **Schema migrations framework.** `__schemaVersion` в persisted-state,
|
||||
`MIGRATIONS` map для будущих структурных правок.
|
||||
- **Modal focus trap + focus restore + aria-labelledby.** Tab/Shift-Tab
|
||||
больше не вываливаются на нижний слой; на закрытии фокус
|
||||
возвращается на триггер.
|
||||
- **Sidebar mobile drawer:** Esc закрывает, focus trap внутри, focus
|
||||
restore на гамбургер, `role="dialog"` + `aria-modal`.
|
||||
- **Tray menu i18n** — пункты меню следуют `settings.language`.
|
||||
- **Bilingual heatmap.** Title, легенда, weekday-лейблы и tooltip
|
||||
с плюрализацией (1 повтор / 2 повтора / 5 повторов) — всё через
|
||||
i18n. 7 новых ключей `weekday.short.*`.
|
||||
- CHANGELOG.md по формату Keep a Changelog.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Critical: данные больше не теряются на corrupt JSON.** Раньше
|
||||
`catch → makeInitial()` молча затирал упражнения/историю. Теперь
|
||||
файл уезжает в `app-state.json.corrupt-<timestamp>`.
|
||||
- **Atomic write через `.tmp` + rename + retry** на EBUSY/EPERM
|
||||
(антивирус, OneDrive). Раньше обрыв питания мог дать truncate.
|
||||
- **HIGH security: GSI server теперь верифицирует auth.token**
|
||||
через `timingSafeEqual` против per-install токена. Раньше
|
||||
эндпоинт был полностью неаутентифицирован — любой локальный
|
||||
процесс мог подделать match-end.
|
||||
- **HIGH security: `shell.openExternal` allowlist** —
|
||||
только `http/https/mailto`. Раньше `file:`/`javascript:`/`steam:`
|
||||
уходили в OS handler.
|
||||
- **HIGH security: dev IPC `simulateMatchEnd`** убран из production
|
||||
билдов (gate на `!app.isPackaged` + `import.meta.env.MODE`).
|
||||
- **HIGH security: GSI server reject `Origin`/`Sec-Fetch-Site`** —
|
||||
блокирует CSRF от browser-вкладок. Body cap 256 KB (OOM-вектор
|
||||
закрыт). Require `application/json`. Generic 400 без error-echo.
|
||||
- **`isQuietAt` wrap-around + day filter.** С `22:00 → 07:00,
|
||||
days=[Mon..Fri]` теперь правильно проверяется день *начала* окна
|
||||
(старт Fri 22:00 → активно ночью Sat 02:00).
|
||||
- **DST drift в `history.ts`.** Календарная арифметика (`setDate`)
|
||||
вместо ms-арифметики — на границе DST дни больше не дублируются.
|
||||
- **Scheduler:** `broadcastState()` после fire, защита от
|
||||
двойной регистрации `powerMonitor` listeners.
|
||||
- **Settings IPC chatter.** QuietTimesRow держит локальное состояние,
|
||||
IPC летит только на `onBlur`. Раньше скрабинг времени давал ~5
|
||||
IPC, каждый переписывал `app-state.json`.
|
||||
- **Dashboard** «До следующего» показывает `—` при паузе вместо
|
||||
обманчиво тикающего таймера.
|
||||
- **HistoryHeatmap** percentile-bucketing (p25/p50/p85) вместо
|
||||
относительной шкалы — outlier-день больше не схлопывает все
|
||||
нормальные дни в самый слабый бакет.
|
||||
- **ReminderApp:** Enter теперь корректно передаёт adjusted reps
|
||||
(раньше всегда planned). `key={exercise.id+nextFireAt}` сбрасывает
|
||||
степпер на новом fire. Степпер capped at 5× planned. Space не
|
||||
работает когда фокус на кнопке. Esc закрывает MatchSummary.
|
||||
- **`i18n.translate`** — split/join вместо regex (var-значения с
|
||||
регулярными метасимволами теперь интерполируются буквально).
|
||||
- **`icon.tsx`** lookup сужен до `ICON_CHOICES` — произвольное имя
|
||||
больше не зарезолвится в `Lucide.default`.
|
||||
- **UpdaterCard NaN guard** на download-progress (electron-updater
|
||||
даёт undefined в ранних событиях).
|
||||
- **`format.ts`** guard от NaN/Infinity в `formatCountdown`.
|
||||
- **`updateExercise`/`updateChallenge`** стрипают `id` из patch —
|
||||
рендер не может перезаписать identity.
|
||||
- **clearHistory(undefined)** теперь no-op (нужен явный boundary).
|
||||
|
||||
### Removed
|
||||
|
||||
- `.gitea/workflows/*.yml` — без runners оставляли queued runs.
|
||||
Релизим через `release.ps1`. has_actions на репо выключен.
|
||||
|
||||
## [0.5.1] — 2026-05-18
|
||||
|
||||
@@ -89,7 +201,9 @@
|
||||
иконки), системный трей, автозапуск с Windows, native-уведомления,
|
||||
NSIS-инсталлятор, auto-update через electron-updater.
|
||||
|
||||
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.1...HEAD
|
||||
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.3...HEAD
|
||||
[0.5.3]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.3
|
||||
[0.5.2]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.2
|
||||
[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
|
||||
|
||||
168
CLAUDE.md
Normal file
168
CLAUDE.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Контекст проекта для Claude Code. Читается при старте каждой сессии.
|
||||
|
||||
## TL;DR
|
||||
|
||||
**Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.3**. Один разработчик (AnRil), один remote — self-hosted Gitea.
|
||||
|
||||
## Стек
|
||||
|
||||
- **Runtime**: Electron 33 (main + preload + renderer)
|
||||
- **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
|
||||
- **Auto-update**: electron-updater 6, generic provider, фиксированный канал
|
||||
- **Тесты**: Vitest 4 (53 теста, все зелёные)
|
||||
- **Lint/format**: ESLint 8 (flat-ish .eslintrc.cjs) + Prettier 3 + EditorConfig
|
||||
- **Иконки**: lucide-react (whitelisted lookup через `ICON_CHOICES`)
|
||||
- **Шрифты**: Plus Jakarta Sans, Bricolage Grotesque, JetBrains Mono (Google Fonts CDN)
|
||||
|
||||
## Архитектура (важное)
|
||||
|
||||
### Процессы
|
||||
- **main** (`src/main/`) — Node, scheduler, GSI HTTP-сервер, IPC, окна, tray, updater, persistence
|
||||
- **preload** (`src/preload/index.ts`) — contextBridge → `window.api`, dev-only методы вырезаны на проде (`import.meta.env.MODE !== 'production'`)
|
||||
- **renderer** (`src/renderer/src/`) — React, zustand-store, страницы Dashboard/Games/Settings/About, ReminderApp в отдельном окне
|
||||
|
||||
### Persistence
|
||||
- Единственный JSON-файл: `%APPDATA%\Exercise Reminder\app-state.json`
|
||||
- **Атомарная запись**: tmp + rename + retry на EBUSY/EPERM (антивирус, OneDrive)
|
||||
- **Не теряет данные**: corrupt JSON → quarantine в `app-state.json.corrupt-<ts>`, не silent wipe
|
||||
- **Schema migrations**: `__schemaVersion` поле + `MIGRATIONS: Record<number, (s)=>s>` map в `src/main/store.ts`
|
||||
- **Debounced writes**: pendingWrite с `.unref()`
|
||||
|
||||
### IPC
|
||||
- Типизированные каналы — `src/shared/ipc.ts`
|
||||
- **Validation layer** — `src/main/validate.ts` (hand-rolled, без zod):
|
||||
- `intervalMinutes ∈ [1, 1440]`, `reps ∈ [1, 9999]`, `multiplier ∈ [0, 1000]`
|
||||
- string cap 200 chars, enum-валидация для theme/lang/notify-mode/stat
|
||||
- HH:MM regex для quietHours, dedup days
|
||||
- Strip `id` из updateExercise/updateChallenge patch
|
||||
- **Dev-only**: `dev:simulateMatchEnd` gated на `!app.isPackaged`
|
||||
|
||||
### Auto-update (КРИТИЧНО)
|
||||
- **Фиксированный URL канала**: `…/releases/download/update-channel/latest.yml` — никогда не меняется
|
||||
- **НЕ** `…/releases/download/v${version}/…` (старая схема ломалась: установленная копия видела только свой релиз)
|
||||
- Hourly silent auto-check (транзитные сетевые ошибки не показывают красный баннер; только ручной клик показывает ошибку)
|
||||
- Boot-check: 3 ретрая с backoff 30s/2m/5m
|
||||
- `lastCheckedAt` → UI «проверено N мин назад»
|
||||
- Релиз через `scripts/release.ps1` публикует одной командой в:
|
||||
1. `vX.Y.Z` (постоянный архивный тег)
|
||||
2. `update-channel` (rolling — клиенты проверяют отсюда)
|
||||
3. Опциональные `-BridgeTags` для миграции старых пользователей
|
||||
|
||||
### Безопасность
|
||||
- **GSI server** (`src/main/games/gsi-server.ts`): per-install token verify через `timingSafeEqual`, reject Origin/Sec-Fetch-Site (CSRF), 256KB body cap, require `application/json`, generic 400
|
||||
- **shell.openExternal allowlist**: только `http:`/`https:`/`mailto:` (`src/main/windows.ts`)
|
||||
- **will-navigate** блокирует non-file:// и non-dev URL
|
||||
- **Modal focus trap** + focus restore, aria-labelledby
|
||||
|
||||
### Quiet hours
|
||||
- `isQuietAt(time, settings)` в `src/shared/types.ts`
|
||||
- Wrap-around (22:00 → 07:00) корректно — при wrap-active проверяется день *начала* окна
|
||||
- Тесты в `src/shared/quiet-hours.test.ts`
|
||||
|
||||
### История / стрики
|
||||
- `src/renderer/src/lib/history.ts` — DST-safe через `shiftDays()` (calendar `setDate`, не ms-арифметика)
|
||||
- Cap 10k записей, trim oldest 10% на overflow
|
||||
- HistoryHeatmap: percentile-based bucketing (p25/p50/p85), а не flat ratio (защищает от outlier-дней)
|
||||
|
||||
### i18n
|
||||
- Самописная микро-система: `src/renderer/src/i18n/dict.ts` (плоский словарь ~200 ключей × 2 языка)
|
||||
- Хук `useT()`, плюрализация CLDR rules для RU (one/few/many)
|
||||
- Интерполяция через split/join (не regex — защита от regex-инъекций в значениях var)
|
||||
- Tray menu тоже локализован (`TRAY_STRINGS` в `src/main/tray.ts`)
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
npm run dev # electron-vite dev
|
||||
npm run typecheck # tsc по node + web
|
||||
npm run test:run # vitest один раз
|
||||
npm run lint # eslint --max-warnings 0
|
||||
npm run format # prettier --write
|
||||
npm run dist # сборка + NSIS installer → release/
|
||||
|
||||
# Релиз (всё в одном)
|
||||
npm run release -- -Bump patch
|
||||
# или -Bump minor / -Bump major / -Version 1.2.3
|
||||
# опционально: -BridgeTags v0.4.0,v0.4.1
|
||||
```
|
||||
|
||||
## Скрипты релиза
|
||||
|
||||
- `scripts/release.ps1` — bump → typecheck → test → build → tag → push → upload в Gitea (vX.Y.Z + update-channel + bridges)
|
||||
- `scripts/upload-release-assets.ps1` — curl.exe с retry/backoff (15s/45s/2m/5m × 4) на 504/TLS, проверяет уже-залилось через list assets перед ретраем
|
||||
- **PowerShell 5.1 gotchas**:
|
||||
- Default reads CP1251 → файлы скриптов **ASCII-only**, без em-dash/кириллицы в коде
|
||||
- `Set-Content -Encoding utf8` добавляет BOM → ломает PostCSS. Для UTF-8 без BOM использовать `[System.IO.File]::WriteAllText` + `new UTF8Encoding($false)`
|
||||
- Никогда `-i` флаги (rebase -i, add -i) — нет interactive input
|
||||
|
||||
## Gitea remote
|
||||
|
||||
- URL: `https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude` (Punycode для `президент.рф`)
|
||||
- User: `anril`
|
||||
- Auth: см. `~/.claude/projects/.../memory/gitea_remote.md`
|
||||
- **Actions выключены** (`has_actions: false`) — релизим через PowerShell, runners не настроены
|
||||
- `.gitea/workflows/` пустая (раньше там лежали yml → queued runs копились)
|
||||
|
||||
## Файлы, которые часто правлю
|
||||
|
||||
| Файл | Что |
|
||||
|---|---|
|
||||
| `package.json` | version, publish.url, scripts, deps |
|
||||
| `src/main/store.ts` | persistence, migrations, validation, atomic writes |
|
||||
| `src/main/ipc.ts` | IPC handlers с валидацией |
|
||||
| `src/main/scheduler.ts` | таймеры упражнений, powerMonitor |
|
||||
| `src/main/games/dota2.ts` + `gsi-server.ts` | GSI приём матчей |
|
||||
| `src/main/updater.ts` | auto-update logic, silent retries |
|
||||
| `src/shared/types.ts` | shared типы, дефолты, isQuietAt |
|
||||
| `src/shared/ipc.ts` | IPC channel types |
|
||||
| `src/renderer/src/i18n/dict.ts` | словари |
|
||||
| `src/renderer/src/pages/Dashboard.tsx` | главная |
|
||||
| `src/renderer/src/ReminderApp.tsx` | окно напоминания |
|
||||
|
||||
## Тесты (53)
|
||||
|
||||
```
|
||||
src/shared/types.test.ts (4)
|
||||
src/shared/quiet-hours.test.ts (7)
|
||||
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)
|
||||
```
|
||||
|
||||
Покрываются: helpers, история/стрики (DST), тихие часы (wrap+filter), VDF-парсер Steam, i18n с плюрализацией, дефолты.
|
||||
|
||||
## Технический долг (не для пользователя)
|
||||
|
||||
- `sandbox: true` на BrowserWindow — нужен smoke-тест preload в sandbox-режиме
|
||||
- Self-host Google Fonts (сейчас внешняя CSP-зависимость)
|
||||
- ReminderApp race: первое напоминание может прийти без озвучки до загрузки settings
|
||||
- Мажорные апдейты (React 18→19, Electron 33→42, Tailwind 3→4) — каждый ломающий, отдельная итерация
|
||||
- Code-signing NSIS — ~$300/год, уберёт SmartScreen warning
|
||||
- Скриншоты в README (есть TODO в самом README)
|
||||
|
||||
## Стиль кода
|
||||
|
||||
- Prettier: semi:false, singleQuote, trailingComma:none, printWidth:80
|
||||
- ESLint: eslint:recommended + ts + react + react-hooks (без style rules — это Prettier)
|
||||
- TypeScript strict, никакого `any` в новом коде
|
||||
- Комментарии на русском там, где объясняют **почему**, не **что**
|
||||
- Коммиты на русском, формат `тип(scope): кратко` (feat/fix/docs/refactor/test/chore)
|
||||
- Co-Authored-By футер в коммитах от Claude
|
||||
|
||||
## Управление контекстом
|
||||
|
||||
- **Ужимать контекст при достижении 250k токенов** — вызывать `/compact` (или эквивалент) когда суммарный контекст подходит к 250 000 токенов. Не дожидаться authentic переполнения и автоматического сжатия от рантайма — сделать это контролируемо, чтобы важный контекст (открытые правки, недокоммиченные решения, текущая ветка задачи) попал в summary, а не выпал.
|
||||
|
||||
## Чего НЕ делать
|
||||
|
||||
- Не пушить в `update-channel` руками — только через `release.ps1`
|
||||
- Не добавлять `.gitea/workflows/*.yml` — has_actions выключен, runs зависнут
|
||||
- Не использовать regex в i18n-интерполяции — только split/join
|
||||
- Не silent wipe corrupt JSON — quarantine с timestamp
|
||||
- Не возвращать ms-арифметику в history.ts — DST сломается
|
||||
- Не убирать validation layer из IPC — compromised renderer может засунуть NaN/негативы
|
||||
- Не амендить коммиты без явной просьбы пользователя
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
|
||||
|
||||
[](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
|
||||
[]()
|
||||
[](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
|
||||
[]()
|
||||
[]()
|
||||
|
||||
## Что внутри
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "laude",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.3",
|
||||
"description": "Exercise reminder — Windows desktop app",
|
||||
"main": "out/main/index.js",
|
||||
"author": "AnRil",
|
||||
|
||||
@@ -176,6 +176,17 @@ export function registerIpc(): void {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||
})
|
||||
|
||||
ipcMain.on(IPC.toggleMaximizeMain, (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
if (win.isMaximized()) win.unmaximize()
|
||||
else win.maximize()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.isMaximizedMain, (event) => {
|
||||
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false
|
||||
})
|
||||
|
||||
ipcMain.on(IPC.closeMain, () => {
|
||||
const main = getMainWindow()
|
||||
if (!main) return
|
||||
|
||||
408
src/main/validate.test.ts
Normal file
408
src/main/validate.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* Тесты для IPC validation layer.
|
||||
*
|
||||
* Этот слой — security-boundary между renderer и main. Если он сломается,
|
||||
* compromised renderer сможет писать в стор NaN, отрицательные, Infinity,
|
||||
* сверхдлинные строки или undefined-enum'ы. Поэтому покрытие важно для:
|
||||
*
|
||||
* 1. Тип-проверок (строка/число/булево/массив)
|
||||
* 2. Range-checks (reps ∈ [1,9999], minutes ∈ [1,1440] и т.д.)
|
||||
* 3. Enum allowlist (theme/lang/notify-mode/stat)
|
||||
* 4. Edge cases: NaN, Infinity, MAX_SAFE_INTEGER, 0, отрицательные, длина строк
|
||||
* 5. Partial-patch semantics (отсутствие поля ≠ невалидное значение)
|
||||
* 6. Сложный nested case: quietHours с HH:MM regex и dedup days
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
validateExerciseInput,
|
||||
validateExercisePatch,
|
||||
validateChallengeInput,
|
||||
validateChallengePatch,
|
||||
validateSettingsPatch,
|
||||
validateId,
|
||||
validateActualReps,
|
||||
validateSnoozeMinutes
|
||||
} from './validate'
|
||||
|
||||
const validExercise = {
|
||||
name: 'Push-ups',
|
||||
reps: 10,
|
||||
intervalMinutes: 30,
|
||||
icon: 'Dumbbell',
|
||||
enabled: true
|
||||
}
|
||||
|
||||
describe('validateExerciseInput', () => {
|
||||
it('accepts a fully-formed valid input', () => {
|
||||
expect(validateExerciseInput(validExercise)).toEqual(validExercise)
|
||||
})
|
||||
|
||||
it('rejects non-objects', () => {
|
||||
expect(validateExerciseInput(null)).toBeNull()
|
||||
expect(validateExerciseInput(undefined)).toBeNull()
|
||||
expect(validateExerciseInput('string')).toBeNull()
|
||||
expect(validateExerciseInput(42)).toBeNull()
|
||||
expect(validateExerciseInput([])).toBeNull() // arrays not allowed
|
||||
})
|
||||
|
||||
it('rejects missing required fields', () => {
|
||||
expect(validateExerciseInput({ ...validExercise, name: undefined })).toBeNull()
|
||||
expect(validateExerciseInput({ ...validExercise, reps: undefined })).toBeNull()
|
||||
expect(
|
||||
validateExerciseInput({ ...validExercise, intervalMinutes: undefined })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects out-of-range reps', () => {
|
||||
expect(validateExerciseInput({ ...validExercise, reps: 0 })).toBeNull()
|
||||
expect(validateExerciseInput({ ...validExercise, reps: -1 })).toBeNull()
|
||||
expect(validateExerciseInput({ ...validExercise, reps: 10_000 })).toBeNull()
|
||||
expect(validateExerciseInput({ ...validExercise, reps: NaN })).toBeNull()
|
||||
expect(
|
||||
validateExerciseInput({ ...validExercise, reps: Infinity })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('truncates reps with Math.trunc (5.7 → 5)', () => {
|
||||
const r = validateExerciseInput({ ...validExercise, reps: 5.7 })
|
||||
expect(r?.reps).toBe(5)
|
||||
})
|
||||
|
||||
it('rejects out-of-range intervalMinutes (> 24h)', () => {
|
||||
expect(
|
||||
validateExerciseInput({ ...validExercise, intervalMinutes: 0 })
|
||||
).toBeNull()
|
||||
expect(
|
||||
validateExerciseInput({ ...validExercise, intervalMinutes: 1441 })
|
||||
).toBeNull()
|
||||
expect(
|
||||
validateExerciseInput({ ...validExercise, intervalMinutes: -1 })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects empty name', () => {
|
||||
expect(validateExerciseInput({ ...validExercise, name: '' })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects name longer than MAX_STR_LEN (200)', () => {
|
||||
expect(
|
||||
validateExerciseInput({ ...validExercise, name: 'x'.repeat(201) })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts name exactly at MAX_STR_LEN', () => {
|
||||
const r = validateExerciseInput({ ...validExercise, name: 'x'.repeat(200) })
|
||||
expect(r?.name).toHaveLength(200)
|
||||
})
|
||||
|
||||
it('defaults icon to Activity if missing', () => {
|
||||
const { icon: _ignored, ...rest } = validExercise
|
||||
void _ignored
|
||||
expect(validateExerciseInput(rest)?.icon).toBe('Activity')
|
||||
})
|
||||
|
||||
it('defaults enabled to true if missing', () => {
|
||||
const { enabled: _ignored, ...rest } = validExercise
|
||||
void _ignored
|
||||
expect(validateExerciseInput(rest)?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
// Дизайн validateExerciseInput: required-поля (name/reps/intervalMinutes)
|
||||
// строгие — невалидное значение reject'ит весь input. Optional-поля
|
||||
// (icon/enabled) lenient — невалидное молча подменяется дефолтом. Это
|
||||
// фиксирует контракт: malicious renderer не сможет создать запись с
|
||||
// reps=-1, но если он пришлёт `enabled: 'yes'`, получит просто enabled=true.
|
||||
it('coerces invalid enabled to true (lenient default for optional fields)', () => {
|
||||
expect(
|
||||
validateExerciseInput({ ...validExercise, enabled: 'yes' })?.enabled
|
||||
).toBe(true)
|
||||
expect(
|
||||
validateExerciseInput({ ...validExercise, enabled: 1 })?.enabled
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// А вот в patch optional-поля строгие — нет defaults, есть `if (v ===
|
||||
// undefined) return null`. Это правильнее: если renderer пришёл с патчем,
|
||||
// в котором есть поле, оно должно быть валидным.
|
||||
it('strict patch: rejects invalid enabled in patch (unlike input)', () => {
|
||||
expect(validateExercisePatch({ enabled: 'yes' })).toBeNull()
|
||||
expect(validateExercisePatch({ enabled: 1 })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects non-string name', () => {
|
||||
expect(validateExerciseInput({ ...validExercise, name: 42 })).toBeNull()
|
||||
expect(validateExerciseInput({ ...validExercise, name: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateExercisePatch', () => {
|
||||
it('accepts an empty patch (no-op update)', () => {
|
||||
expect(validateExercisePatch({})).toEqual({})
|
||||
})
|
||||
|
||||
it('accepts partial patches', () => {
|
||||
expect(validateExercisePatch({ reps: 12 })).toEqual({ reps: 12 })
|
||||
expect(validateExercisePatch({ name: 'New' })).toEqual({ name: 'New' })
|
||||
expect(validateExercisePatch({ enabled: false })).toEqual({ enabled: false })
|
||||
})
|
||||
|
||||
it('rejects patch with a single invalid field', () => {
|
||||
// Patch is all-or-nothing: one bad field rejects the whole patch.
|
||||
expect(validateExercisePatch({ name: 'OK', reps: -1 })).toBeNull()
|
||||
expect(validateExercisePatch({ name: '', reps: 10 })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects non-object', () => {
|
||||
expect(validateExercisePatch(null)).toBeNull()
|
||||
expect(validateExercisePatch([])).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts nextFireAt and lastDoneAt with valid ranges', () => {
|
||||
expect(validateExercisePatch({ nextFireAt: 0 })).toEqual({ nextFireAt: 0 })
|
||||
expect(validateExercisePatch({ lastDoneAt: 1_000_000_000_000 })).toEqual({
|
||||
lastDoneAt: 1_000_000_000_000
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects negative timestamps', () => {
|
||||
expect(validateExercisePatch({ nextFireAt: -1 })).toBeNull()
|
||||
expect(validateExercisePatch({ lastDoneAt: -1 })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects NaN/Infinity timestamps', () => {
|
||||
expect(validateExercisePatch({ nextFireAt: NaN })).toBeNull()
|
||||
expect(validateExercisePatch({ nextFireAt: Infinity })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateChallengeInput', () => {
|
||||
const valid = {
|
||||
name: 'Deaths → squats',
|
||||
gameId: 'dota2',
|
||||
stat: 'deaths' as const,
|
||||
multiplier: 3,
|
||||
exerciseName: 'Приседания',
|
||||
icon: 'Activity',
|
||||
enabled: true
|
||||
}
|
||||
|
||||
it('accepts valid input', () => {
|
||||
expect(validateChallengeInput(valid)).toEqual(valid)
|
||||
})
|
||||
|
||||
it('rejects unknown stat', () => {
|
||||
expect(validateChallengeInput({ ...valid, stat: 'pizza' })).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts all valid stats', () => {
|
||||
const stats = ['deaths', 'kills', 'assists', 'last_hits', 'denies', 'duration_min']
|
||||
for (const stat of stats) {
|
||||
expect(validateChallengeInput({ ...valid, stat })).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects negative multiplier', () => {
|
||||
expect(validateChallengeInput({ ...valid, multiplier: -1 })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects multiplier > 1000', () => {
|
||||
expect(validateChallengeInput({ ...valid, multiplier: 1001 })).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts zero multiplier (legitimate "disable" semantics)', () => {
|
||||
expect(validateChallengeInput({ ...valid, multiplier: 0 })?.multiplier).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts fractional multiplier (e.g. 0.5×)', () => {
|
||||
expect(validateChallengeInput({ ...valid, multiplier: 0.5 })?.multiplier).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateChallengePatch', () => {
|
||||
it('accepts empty patch', () => {
|
||||
expect(validateChallengePatch({})).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects unknown stat in patch', () => {
|
||||
expect(validateChallengePatch({ stat: 'mana' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSettingsPatch', () => {
|
||||
it('accepts empty patch', () => {
|
||||
expect(validateSettingsPatch({})).toEqual({})
|
||||
})
|
||||
|
||||
it('accepts each boolean toggle independently', () => {
|
||||
expect(validateSettingsPatch({ globalEnabled: false })).toEqual({
|
||||
globalEnabled: false
|
||||
})
|
||||
expect(validateSettingsPatch({ soundEnabled: true })).toEqual({
|
||||
soundEnabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unknown theme', () => {
|
||||
expect(validateSettingsPatch({ theme: 'sepia' })).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts all valid themes', () => {
|
||||
expect(validateSettingsPatch({ theme: 'light' })?.theme).toBe('light')
|
||||
expect(validateSettingsPatch({ theme: 'dark' })?.theme).toBe('dark')
|
||||
expect(validateSettingsPatch({ theme: 'system' })?.theme).toBe('system')
|
||||
})
|
||||
|
||||
it('rejects unknown language', () => {
|
||||
expect(validateSettingsPatch({ language: 'fr' })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects unknown notification mode', () => {
|
||||
expect(validateSettingsPatch({ notificationMode: 'sms' })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects out-of-range snoozeMinutes', () => {
|
||||
expect(validateSettingsPatch({ snoozeMinutes: 0 })).toBeNull()
|
||||
expect(validateSettingsPatch({ snoozeMinutes: 1441 })).toBeNull()
|
||||
expect(validateSettingsPatch({ snoozeMinutes: -5 })).toBeNull()
|
||||
})
|
||||
|
||||
describe('quietHours subobject', () => {
|
||||
const baseQh = {
|
||||
enabled: true,
|
||||
from: '22:00',
|
||||
to: '08:00',
|
||||
days: [0, 1, 2, 3, 4, 5, 6]
|
||||
}
|
||||
|
||||
it('accepts a valid quietHours', () => {
|
||||
expect(validateSettingsPatch({ quietHours: baseQh })?.quietHours).toEqual(
|
||||
baseQh
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects non-object quietHours', () => {
|
||||
expect(validateSettingsPatch({ quietHours: 'always' })).toBeNull()
|
||||
expect(validateSettingsPatch({ quietHours: null })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects malformed HH:MM', () => {
|
||||
expect(
|
||||
validateSettingsPatch({ quietHours: { ...baseQh, from: '2500' } })
|
||||
).toBeNull()
|
||||
expect(
|
||||
validateSettingsPatch({ quietHours: { ...baseQh, to: 'bedtime' } })
|
||||
).toBeNull()
|
||||
expect(
|
||||
validateSettingsPatch({ quietHours: { ...baseQh, from: '8' } })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts HH:MM with 1-digit hour (9:30)', () => {
|
||||
// Regex is /^\d{1,2}:\d{2}$/ — допускаем «9:30», парсер сам разберётся.
|
||||
const r = validateSettingsPatch({
|
||||
quietHours: { ...baseQh, from: '9:30' }
|
||||
})
|
||||
expect(r?.quietHours?.from).toBe('9:30')
|
||||
})
|
||||
|
||||
it('dedupes days array', () => {
|
||||
const r = validateSettingsPatch({
|
||||
quietHours: { ...baseQh, days: [1, 2, 2, 3, 1] }
|
||||
})
|
||||
expect(r?.quietHours?.days).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('rejects out-of-range day (7)', () => {
|
||||
expect(
|
||||
validateSettingsPatch({ quietHours: { ...baseQh, days: [0, 7] } })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects negative day', () => {
|
||||
expect(
|
||||
validateSettingsPatch({ quietHours: { ...baseQh, days: [-1] } })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects non-array days', () => {
|
||||
expect(
|
||||
validateSettingsPatch({ quietHours: { ...baseQh, days: 'all' } })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts empty days array (window effectively disabled)', () => {
|
||||
const r = validateSettingsPatch({
|
||||
quietHours: { ...baseQh, days: [] }
|
||||
})
|
||||
expect(r?.quietHours?.days).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateId', () => {
|
||||
it('accepts reasonable id strings', () => {
|
||||
expect(validateId('abc')).toBe('abc')
|
||||
expect(validateId('uuid-v4-style-thing-123')).toBe('uuid-v4-style-thing-123')
|
||||
})
|
||||
|
||||
it('rejects non-strings', () => {
|
||||
expect(validateId(42)).toBeNull()
|
||||
expect(validateId(null)).toBeNull()
|
||||
expect(validateId(undefined)).toBeNull()
|
||||
expect(validateId({})).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(validateId('')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects strings longer than 64 chars', () => {
|
||||
expect(validateId('x'.repeat(65))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateActualReps', () => {
|
||||
it('returns undefined for undefined/null (means: use planned reps)', () => {
|
||||
expect(validateActualReps(undefined)).toBeUndefined()
|
||||
expect(validateActualReps(null)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts zero (partial completion = "did 0 of 10")', () => {
|
||||
expect(validateActualReps(0)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts large values up to cap', () => {
|
||||
expect(validateActualReps(100_000)).toBe(100_000)
|
||||
})
|
||||
|
||||
it('rejects negative', () => {
|
||||
expect(validateActualReps(-1)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects values above cap', () => {
|
||||
expect(validateActualReps(100_001)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects NaN/Infinity', () => {
|
||||
expect(validateActualReps(NaN)).toBeUndefined()
|
||||
expect(validateActualReps(Infinity)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSnoozeMinutes', () => {
|
||||
it('accepts valid minutes', () => {
|
||||
expect(validateSnoozeMinutes(15)).toBe(15)
|
||||
expect(validateSnoozeMinutes(1)).toBe(1)
|
||||
expect(validateSnoozeMinutes(1440)).toBe(1440)
|
||||
})
|
||||
|
||||
it('rejects 0 and above 24h', () => {
|
||||
expect(validateSnoozeMinutes(0)).toBeNull()
|
||||
expect(validateSnoozeMinutes(1441)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects non-numbers', () => {
|
||||
expect(validateSnoozeMinutes('15')).toBeNull()
|
||||
expect(validateSnoozeMinutes(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BrowserWindow, shell, screen, app, nativeImage } from 'electron'
|
||||
import { IPC } from '../shared/ipc'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -90,8 +91,13 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: 1100,
|
||||
height: 720,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
// Минимум подобран так, чтобы:
|
||||
// - срабатывал Tailwind `lg:` (≥1024px) → 4 hero-stat в один ряд, а не 2×2
|
||||
// - сайдбар (256px) + контент (max-w-5xl, padding lg:px-10) помещались без
|
||||
// горизонтального скролла heatmap'а и карточек упражнений
|
||||
// - по вертикали оставался запас на header + stats + heatmap без обрезки
|
||||
minWidth: 1100,
|
||||
minHeight: 700,
|
||||
show: false,
|
||||
frame: false,
|
||||
backgroundColor: '#0f1117',
|
||||
@@ -110,6 +116,16 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
|
||||
if (showImmediately) win.show()
|
||||
})
|
||||
|
||||
// Сообщаем рендереру об изменении max-состояния, чтобы он мог менять
|
||||
// иконку (квадрат ↔ «двойной квадрат») в кастомном тайтлбаре.
|
||||
const emitMaxState = (maximized: boolean): void => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send(IPC.evtMaximizeChanged, maximized)
|
||||
}
|
||||
}
|
||||
win.on('maximize', () => emitMaxState(true))
|
||||
win.on('unmaximize', () => emitMaxState(false))
|
||||
|
||||
installSafeNavigation(win)
|
||||
|
||||
loadRoute(win, 'main')
|
||||
|
||||
@@ -54,6 +54,9 @@ const api = {
|
||||
reminderClose: (): Promise<void> => ipcRenderer.invoke(IPC.reminderClose),
|
||||
|
||||
minimizeMain: (): void => ipcRenderer.send(IPC.minimizeMain),
|
||||
toggleMaximizeMain: (): void => ipcRenderer.send(IPC.toggleMaximizeMain),
|
||||
isMaximizedMain: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke(IPC.isMaximizedMain),
|
||||
closeMain: (): void => ipcRenderer.send(IPC.closeMain),
|
||||
hideMain: (): void => ipcRenderer.send(IPC.hideMain),
|
||||
|
||||
@@ -121,7 +124,9 @@ const api = {
|
||||
onGamesChanged: (h: Handler<GameStatus[]>): Unsub =>
|
||||
on(IPC.evtGamesChanged, h),
|
||||
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
|
||||
on(IPC.evtUpdaterStatus, h)
|
||||
on(IPC.evtUpdaterStatus, h),
|
||||
onMaximizeChanged: (h: Handler<boolean>): Unsub =>
|
||||
on(IPC.evtMaximizeChanged, h)
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Minus, X, Square, Menu } from 'lucide-react'
|
||||
import { Minus, X, Square, Copy, Menu } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useT } from '../i18n'
|
||||
|
||||
type Props = {
|
||||
@@ -10,8 +11,29 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
|
||||
const { t } = useT()
|
||||
const effectiveTitle = title ?? t('titlebar.app_title')
|
||||
|
||||
// Локально отслеживаем maximize-state, чтобы свапать иконку (квадрат ↔
|
||||
// «двойной квадрат», как в нативной винде). Стартовое значение спрашиваем
|
||||
// у main; дальше подписываемся на evtMaximizeChanged.
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
useEffect(() => {
|
||||
void window.api.isMaximizedMain().then(setMaximized)
|
||||
const unsub = window.api.onMaximizeChanged((v) => setMaximized(v))
|
||||
return unsub
|
||||
}, [])
|
||||
|
||||
// Double-click по тайтлбару — стандартный Windows-жест для toggle maximize.
|
||||
// Игнорируем клики по элементам с no-drag (кнопки/меню) — у них своя логика.
|
||||
function onDoubleClick(e: React.MouseEvent<HTMLDivElement>): void {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('.titlebar-nodrag')) return
|
||||
window.api.toggleMaximizeMain()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b">
|
||||
<div
|
||||
onDoubleClick={onDoubleClick}
|
||||
className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b"
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
|
||||
{onMenuClick && (
|
||||
<button
|
||||
@@ -28,7 +50,10 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
|
||||
{effectiveTitle}
|
||||
</div>
|
||||
|
||||
<div className="titlebar-nodrag flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
|
||||
{/* no-drag навешен на сами кнопки, не на обёртку: иначе из-за
|
||||
flex-1 basis-0 весь кластер (включая пустое место слева от кнопок)
|
||||
становится no-drag, и окно нельзя ухватить рядом с кнопками. */}
|
||||
<div className="flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
|
||||
<WinBtn
|
||||
onClick={() => window.api.minimizeMain()}
|
||||
label={t('titlebar.minimize_aria')}
|
||||
@@ -36,10 +61,18 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
|
||||
<Minus size={13} strokeWidth={2} />
|
||||
</WinBtn>
|
||||
<WinBtn
|
||||
onClick={() => window.api.hideMain()}
|
||||
label={t('titlebar.tray_aria')}
|
||||
onClick={() => window.api.toggleMaximizeMain()}
|
||||
label={
|
||||
maximized
|
||||
? t('titlebar.restore_aria')
|
||||
: t('titlebar.maximize_aria')
|
||||
}
|
||||
>
|
||||
{maximized ? (
|
||||
<Copy size={11} strokeWidth={2} />
|
||||
) : (
|
||||
<Square size={11} strokeWidth={2} />
|
||||
)}
|
||||
</WinBtn>
|
||||
<WinBtn
|
||||
onClick={() => window.api.closeMain()}
|
||||
@@ -69,7 +102,7 @@ function WinBtn({
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className={[
|
||||
'w-9 h-7 grid place-items-center rounded-md transition-colors text-text/55',
|
||||
'titlebar-nodrag w-9 h-7 grid place-items-center rounded-md transition-colors text-text/55',
|
||||
danger
|
||||
? 'hover:bg-destructive hover:text-white'
|
||||
: 'hover:bg-text/[0.08] hover:text-text'
|
||||
|
||||
@@ -21,6 +21,8 @@ export const ru: Dict = {
|
||||
'sidebar.status_tracking': 'Активность отслеживается',
|
||||
'titlebar.menu_aria': 'Меню',
|
||||
'titlebar.minimize_aria': 'Свернуть',
|
||||
'titlebar.maximize_aria': 'Развернуть',
|
||||
'titlebar.restore_aria': 'Восстановить размер',
|
||||
'titlebar.tray_aria': 'В трей',
|
||||
'titlebar.close_aria': 'Закрыть',
|
||||
'titlebar.app_title': 'Exercise Reminder',
|
||||
@@ -265,6 +267,8 @@ export const en: Dict = {
|
||||
'sidebar.status_tracking': 'Activity tracking is on',
|
||||
'titlebar.menu_aria': 'Menu',
|
||||
'titlebar.minimize_aria': 'Minimize',
|
||||
'titlebar.maximize_aria': 'Maximize',
|
||||
'titlebar.restore_aria': 'Restore size',
|
||||
'titlebar.tray_aria': 'To tray',
|
||||
'titlebar.close_aria': 'Close',
|
||||
'titlebar.app_title': 'Exercise Reminder',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { translate, translateN } from './index'
|
||||
import { ru, en } from './dict'
|
||||
|
||||
describe('translate', () => {
|
||||
it('returns the matching string by key', () => {
|
||||
@@ -30,6 +31,50 @@ describe('translate', () => {
|
||||
// @ts-expect-error testing fallback
|
||||
expect(translate('fr', 'btn.save')).toBe('Сохранить')
|
||||
})
|
||||
|
||||
// Регрессия: до v0.5.2 интерполяция шла через regex, и если
|
||||
// var-значение содержало regex-метасимволы ($1, .*, и т.д.), они
|
||||
// интерпретировались как backreferences. Сейчас split/join.
|
||||
it('substitutes regex metacharacters literally (no regex injection)', () => {
|
||||
expect(
|
||||
translate('ru', 'btn.snooze_min', { n: '$1.*' as unknown as number })
|
||||
).toBe('Отложить $1.* мин')
|
||||
expect(
|
||||
translate('en', 'btn.snooze_min', {
|
||||
n: '$$$&\\1' as unknown as number
|
||||
})
|
||||
).toBe('Snooze $$$&\\1m')
|
||||
})
|
||||
|
||||
it('leaves unsubstituted placeholders intact', () => {
|
||||
// {n} остаётся как есть, если var не передан — это сигнал «забыл vars».
|
||||
expect(translate('ru', 'btn.snooze_min')).toContain('{n}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dictionary parity', () => {
|
||||
// EN не имеет CLDR-категории `few` — только `one`/`many`. Поэтому RU-ключи
|
||||
// вида `*_few` легитимно отсутствуют в EN, исключаем их из парити-чека.
|
||||
const isRuFewOnly = (k: string): boolean => k.endsWith('_few')
|
||||
|
||||
it('every key in ru (except *_few) exists in en', () => {
|
||||
const missing = Object.keys(ru).filter(
|
||||
(k) => !isRuFewOnly(k) && !(k in en)
|
||||
)
|
||||
expect(missing, `missing in en: ${missing.join(', ')}`).toEqual([])
|
||||
})
|
||||
|
||||
it('every key in en exists in ru', () => {
|
||||
const missing = Object.keys(en).filter((k) => !(k in ru))
|
||||
expect(missing, `missing in ru: ${missing.join(', ')}`).toEqual([])
|
||||
})
|
||||
|
||||
it('weekday.short.0..6 exist in both languages', () => {
|
||||
for (const i of [0, 1, 2, 3, 4, 5, 6]) {
|
||||
expect(ru[`weekday.short.${i}`]).toBeTruthy()
|
||||
expect(en[`weekday.short.${i}`]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('translateN (plural)', () => {
|
||||
|
||||
@@ -33,6 +33,29 @@ describe('formatCountdown', () => {
|
||||
expect(formatCountdown(999)).toBe('0с')
|
||||
expect(formatCountdown(500)).toBe('0с')
|
||||
})
|
||||
|
||||
// Guard added in v0.5.2 — electron-updater и scheduler могут передать
|
||||
// NaN/Infinity на ранних событиях. Должны вернуть «сейчас», не «NaNс».
|
||||
it('returns "сейчас" for NaN and Infinity (defensive guard)', () => {
|
||||
expect(formatCountdown(NaN)).toBe('сейчас')
|
||||
expect(formatCountdown(Infinity)).toBe('сейчас')
|
||||
expect(formatCountdown(-Infinity)).toBe('сейчас')
|
||||
})
|
||||
|
||||
describe('english locale', () => {
|
||||
it('renders sub-minute with "s"', () => {
|
||||
expect(formatCountdown(45_000, 'en')).toBe('45s')
|
||||
})
|
||||
it('renders minutes+seconds with "m"/"s"', () => {
|
||||
expect(formatCountdown(65_000, 'en')).toBe('1m 05s')
|
||||
})
|
||||
it('renders hours+minutes with "h"/"m"', () => {
|
||||
expect(formatCountdown(3_660_000, 'en')).toBe('1h 01m')
|
||||
})
|
||||
it('returns "now" for zero', () => {
|
||||
expect(formatCountdown(0, 'en')).toBe('now')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatInterval', () => {
|
||||
@@ -53,4 +76,10 @@ describe('formatInterval', () => {
|
||||
expect(formatInterval(90)).toBe('1 ч 30 мин')
|
||||
expect(formatInterval(125)).toBe('2 ч 5 мин')
|
||||
})
|
||||
|
||||
it('english locale', () => {
|
||||
expect(formatInterval(30, 'en')).toBe('30 min')
|
||||
expect(formatInterval(60, 'en')).toBe('1 h')
|
||||
expect(formatInterval(90, 'en')).toBe('1 h 30 min')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Exercise, HistoryEntry } from '@shared/types'
|
||||
import { currentStreak, dailyReps, dayKey, dailyRepsRange } from './history'
|
||||
import {
|
||||
currentStreak,
|
||||
dailyReps,
|
||||
dayKey,
|
||||
dailyRepsRange,
|
||||
plannedRepsToday
|
||||
} from './history'
|
||||
|
||||
const MS_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
@@ -117,4 +123,77 @@ describe('dailyRepsRange', () => {
|
||||
expect(range.at(-1)?.reps).toBe(10) // today
|
||||
expect(range.at(-2)?.reps).toBe(3) // yesterday, partial
|
||||
})
|
||||
|
||||
// DST regression: до v0.5.2 dailyRepsRange использовал `ts - i*MS_DAY`.
|
||||
// На границе DST (например в EU last Sunday October — 25 час) арифметика
|
||||
// ms-vs-календарь расходилась, и dayKey() выдавал дубликат/пропуск дня.
|
||||
// Сейчас shiftDays() через setDate(). Простой инвариант: количество
|
||||
// уникальных day-keys всегда == days, и все keys строго возрастают.
|
||||
it('produces unique day keys without gaps (DST-safe)', () => {
|
||||
const range = dailyRepsRange([], [], 90)
|
||||
const keys = range.map((r) => r.key)
|
||||
expect(new Set(keys).size).toBe(90)
|
||||
for (let i = 1; i < keys.length; i++) {
|
||||
expect(keys[i] > keys[i - 1]).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('last entry is today', () => {
|
||||
const range = dailyRepsRange([], [], 7)
|
||||
expect(range.at(-1)?.key).toBe(dayKey(Date.now()))
|
||||
})
|
||||
})
|
||||
|
||||
describe('plannedRepsToday', () => {
|
||||
it('returns 0 when no exercises enabled', () => {
|
||||
const exs = [{ ...ex('a', 10), enabled: false }]
|
||||
expect(plannedRepsToday(exs)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 for empty list', () => {
|
||||
expect(plannedRepsToday([])).toBe(0)
|
||||
})
|
||||
|
||||
it('multiplies reps by approximate fires per day', () => {
|
||||
// 60-min interval × 24 = 24 fires/day × 10 reps = 240
|
||||
const exs = [{ ...ex('a', 10), intervalMinutes: 60 }]
|
||||
expect(plannedRepsToday(exs)).toBe(240)
|
||||
})
|
||||
|
||||
it('sums across multiple enabled exercises', () => {
|
||||
const exs = [
|
||||
{ ...ex('a', 10), intervalMinutes: 60 }, // 24 × 10 = 240
|
||||
{ ...ex('b', 5), intervalMinutes: 30 } // 48 × 5 = 240
|
||||
]
|
||||
expect(plannedRepsToday(exs)).toBe(480)
|
||||
})
|
||||
|
||||
it('floor of (1440/interval), minimum 1 fire/day for huge intervals', () => {
|
||||
// 1440-min interval = 1 fire/day; 2000-min interval should still be ≥ 1.
|
||||
const exs = [{ ...ex('a', 7), intervalMinutes: 2000 }]
|
||||
expect(plannedRepsToday(exs)).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentStreak edge cases', () => {
|
||||
const today = Date.now()
|
||||
|
||||
it('ignores future-dated entries (clock skew, partial restore)', () => {
|
||||
const tomorrow = today + 24 * 60 * 60 * 1000
|
||||
// future entry shouldn't anchor the streak.
|
||||
expect(currentStreak([entry('a', tomorrow)])).toBe(0)
|
||||
})
|
||||
|
||||
it('handles entries spread across the same day with mixed actions', () => {
|
||||
const e = (
|
||||
action: 'done' | 'skip' | 'snooze',
|
||||
ts: number
|
||||
): HistoryEntry => entry('a', ts, action)
|
||||
const hist = [
|
||||
e('skip', today),
|
||||
e('done', today), // done is enough — streak counts the day
|
||||
e('snooze', today)
|
||||
]
|
||||
expect(currentStreak(hist)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
26
src/renderer/src/lib/icon-choices.test.ts
Normal file
26
src/renderer/src/lib/icon-choices.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ICON_CHOICES } from './icon-choices'
|
||||
import { SAMPLE_EXERCISES } from '@shared/types'
|
||||
|
||||
describe('ICON_CHOICES', () => {
|
||||
// Если иконка SAMPLE_EXERCISES не входит в whitelist, при первом запуске
|
||||
// приложения иконка молча заменится на fallback-Activity. Лучше ловить
|
||||
// расхождение в CI.
|
||||
it('contains every icon used by SAMPLE_EXERCISES', () => {
|
||||
const allowed = new Set<string>(ICON_CHOICES)
|
||||
for (const ex of SAMPLE_EXERCISES) {
|
||||
expect(
|
||||
allowed.has(ex.icon),
|
||||
`icon "${ex.icon}" for sample "${ex.name}" is not in ICON_CHOICES`
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('has no duplicates', () => {
|
||||
expect(new Set(ICON_CHOICES).size).toBe(ICON_CHOICES.length)
|
||||
})
|
||||
|
||||
it('is non-empty', () => {
|
||||
expect(ICON_CHOICES.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
27
src/renderer/src/lib/icon-choices.ts
Normal file
27
src/renderer/src/lib/icon-choices.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Whitelist of allowed Lucide-icon names. Wrapped in a separate .ts file
|
||||
* (без JSX), чтобы его можно было импортировать из node-tests и из shared/
|
||||
* без подтягивания JSX-зависимости icon.tsx.
|
||||
*/
|
||||
export const ICON_CHOICES = [
|
||||
'Activity',
|
||||
'Dumbbell',
|
||||
'StretchHorizontal',
|
||||
'PersonStanding',
|
||||
'Heart',
|
||||
'Footprints',
|
||||
'Hand',
|
||||
'Eye',
|
||||
'Brain',
|
||||
'Bike',
|
||||
'Waves',
|
||||
'Wind',
|
||||
'Sun',
|
||||
'Coffee',
|
||||
'Apple',
|
||||
'GlassWater',
|
||||
'BookOpen',
|
||||
'Sparkles'
|
||||
] as const
|
||||
|
||||
export type IconName = (typeof ICON_CHOICES)[number]
|
||||
@@ -1,28 +1,9 @@
|
||||
import * as Lucide from 'lucide-react'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
import { ICON_CHOICES, type IconName } from './icon-choices'
|
||||
|
||||
export const ICON_CHOICES = [
|
||||
'Activity',
|
||||
'Dumbbell',
|
||||
'StretchHorizontal',
|
||||
'PersonStanding',
|
||||
'Heart',
|
||||
'Footprints',
|
||||
'Hand',
|
||||
'Eye',
|
||||
'Brain',
|
||||
'Bike',
|
||||
'Waves',
|
||||
'Wind',
|
||||
'Sun',
|
||||
'Coffee',
|
||||
'Apple',
|
||||
'GlassWater',
|
||||
'BookOpen',
|
||||
'Sparkles'
|
||||
] as const
|
||||
|
||||
export type IconName = (typeof ICON_CHOICES)[number]
|
||||
// Re-export для обратной совместимости с импортёрами icon.tsx.
|
||||
export { ICON_CHOICES, type IconName }
|
||||
|
||||
const ICON_SET = new Set<string>(ICON_CHOICES)
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export const IPC = {
|
||||
resumeAll: 'app:resumeAll',
|
||||
quit: 'app:quit',
|
||||
minimizeMain: 'window:minimize',
|
||||
toggleMaximizeMain: 'window:toggleMaximize',
|
||||
isMaximizedMain: 'window:isMaximized',
|
||||
closeMain: 'window:close',
|
||||
hideMain: 'window:hide',
|
||||
|
||||
@@ -54,5 +56,6 @@ export const IPC = {
|
||||
evtThemeChanged: 'evt:themeChanged',
|
||||
evtAccentChanged: 'evt:accentChanged',
|
||||
evtGamesChanged: 'evt:gamesChanged',
|
||||
evtUpdaterStatus: 'evt:updaterStatus'
|
||||
evtUpdaterStatus: 'evt:updaterStatus',
|
||||
evtMaximizeChanged: 'evt:maximizeChanged'
|
||||
} as const
|
||||
|
||||
@@ -30,7 +30,11 @@ describe('SAMPLE_EXERCISES', () => {
|
||||
expect(ex.icon.length, `icon set for ${ex.name}`).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
// NB: тест «sample icons ⊆ ICON_CHOICES» лежит в
|
||||
// src/renderer/src/lib/icon-choices.test.ts — он тянет renderer-сторону
|
||||
// (ICON_CHOICES), а node-tsconfig сюда не пускает renderer-импорты.
|
||||
|
||||
describe('STAT_LABELS', () => {
|
||||
it('has a Russian label for every GameStat in every GAME_STATS bundle', () => {
|
||||
|
||||
Reference in New Issue
Block a user