Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f038e59e8 | ||
|
|
33e237948e | ||
|
|
f861af5db1 | ||
|
|
c9d4fc237e | ||
|
|
973339ca62 | ||
|
|
70eb4717ec |
93
README.md
Normal file
93
README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Laude — Exercise Reminder
|
||||||
|
|
||||||
|
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
|
||||||
|
|
||||||
|
[](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
|
||||||
|
[]()
|
||||||
|
[]()
|
||||||
|
|
||||||
|
## Что внутри
|
||||||
|
|
||||||
|
- **Гибкие напоминания** — любое количество упражнений, интервал от минуты до часов, разные иконки.
|
||||||
|
- **История и стрики** — heatmap-календарь активности, ежедневный счётчик, серия дней подряд.
|
||||||
|
- **Тихие часы** — окно времени когда напоминания подавляются (например `22:00 → 08:00`), с выбором дней недели.
|
||||||
|
- **Сделал частично** — степпер `−/+` в окне напоминания: если ты сделал 5 из 10, в историю запишется честное число.
|
||||||
|
- **Игровая интеграция (Dota 2)** — Game State Integration читает статистику матча, после Победа/Поражение показывает экран с «причитающимися» повторениями (например `10 смертей × 3 = 30 приседаний`).
|
||||||
|
- **Apple-style интерфейс** — Plus Jakarta Sans + Bricolage Grotesque, iOS-палитра, vibrancy sidebar, spring-анимации, светлая/тёмная/системная тема.
|
||||||
|
- **Два языка** — русский и английский, переключение мгновенное.
|
||||||
|
- **Auto-update** — приложение само скачивает новые версии из Gitea release (проверка каждый час).
|
||||||
|
|
||||||
|
## Скриншоты
|
||||||
|
|
||||||
|
> _TODO: вставить screenshots Dashboard / Reminder / Match summary (light + dark)._
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Скачай последний `Exercise-Reminder-Setup-X.Y.Z.exe` со страницы релизов и запусти. Установщик:
|
||||||
|
|
||||||
|
- Создаёт ярлык на рабочем столе и в Пуске
|
||||||
|
- Сохраняет настройки в `%APPDATA%\Exercise Reminder\`
|
||||||
|
- При запуске поверх существующей инсталляции — обновляет, настройки сохраняются
|
||||||
|
|
||||||
|
Windows SmartScreen может предупредить «не доверено» — приложение не подписано code-signing сертификатом. Нажми `Подробнее` → `Выполнить в любом случае`.
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude.git
|
||||||
|
cd laude
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Полезные команды:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck # tsc по main + renderer
|
||||||
|
npm run test # vitest в watch-режиме
|
||||||
|
npm run test:run # vitest один раз (для CI)
|
||||||
|
npm run build # сборка без NSIS
|
||||||
|
npm run dist # сборка + NSIS-инсталлятор → release/
|
||||||
|
npm run release -- -Bump patch # bump версии + tag + push + upload в Gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
Документ `RELEASING.md` описывает процесс выпуска новых версий.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
- **Electron 33** — multi-process: main (Node/scheduler/GSI) + preload (contextBridge) + renderer (React)
|
||||||
|
- **Renderer** — React 18, TypeScript 5, Vite 5, Tailwind 3, framer-motion, react-router, zustand
|
||||||
|
- **Persistence** — единственный JSON-файл `%APPDATA%\Exercise Reminder\app-state.json` (debounced writes)
|
||||||
|
- **IPC** — типизированные каналы через `src/shared/ipc.ts`, обёрнуто preload-ом
|
||||||
|
- **i18n** — самописная микро-система: `src/renderer/src/i18n/dict.ts` (плоский словарь ~200 ключей × 2 языка) + хук `useT()`
|
||||||
|
- **Auto-update** — `electron-updater` с `generic` provider, манифест `latest.yml` лежит в Gitea release attachments
|
||||||
|
- **GSI Dota 2** — локальный HTTP-сервер слушает GameStateIntegration коллбэки от Steam, парсит match-end events
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```
|
||||||
|
src/shared/types.test.ts (4)
|
||||||
|
src/renderer/src/lib/format.test.ts (8)
|
||||||
|
src/main/games/vdf.test.ts (11)
|
||||||
|
src/renderer/src/i18n/i18n.test.ts (10)
|
||||||
|
─────────────────────────────────────
|
||||||
|
33 ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
Покрытие: чистые helpers (форматирование, парсер VDF для Steam-конфигов), i18n с плюрализацией для RU/EN, дефолты shared-типов.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Пока не указана. По умолчанию все права защищены. Если хочешь форк/использование — открой issue.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- [Electron](https://www.electronjs.org/) · runtime
|
||||||
|
- [electron-vite](https://electron-vite.org/) · build
|
||||||
|
- [React](https://react.dev/) + [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/) · стили
|
||||||
|
- [framer-motion](https://motion.dev/) · анимации
|
||||||
|
- [lucide-react](https://lucide.dev/) · иконки
|
||||||
|
- [electron-updater](https://www.electron.build/auto-update) · auto-update
|
||||||
|
- [Vitest](https://vitest.dev/) · тесты
|
||||||
|
- Шрифты: [Plus Jakarta Sans](https://fonts.google.com/specimen/Plus+Jakarta+Sans), [Bricolage Grotesque](https://fonts.google.com/specimen/Bricolage+Grotesque), [JetBrains Mono](https://fonts.google.com/specimen/JetBrains+Mono)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "laude",
|
"name": "laude",
|
||||||
"version": "0.3.6",
|
"version": "0.5.1",
|
||||||
"description": "Exercise reminder — Windows desktop app",
|
"description": "Exercise reminder — Windows desktop app",
|
||||||
"main": "out/main/index.js",
|
"main": "out/main/index.js",
|
||||||
"author": "AnRil",
|
"author": "AnRil",
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
},
|
},
|
||||||
"publish": {
|
"publish": {
|
||||||
"provider": "generic",
|
"provider": "generic",
|
||||||
"url": "https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/download/v${version}",
|
"url": "https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/download/update-channel",
|
||||||
"channel": "latest"
|
"channel": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,78 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Локальный релиз: бамп версии → коммит → тег → push → сборка → upload в Gitea release.
|
Локальный релиз: бамп версии -> коммит -> тег -> push -> сборка -> upload в Gitea.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Один скрипт от и до. Если Gitea Actions не настроено, это рабочая альтернатива.
|
Single-command release flow.
|
||||||
|
|
||||||
|
Каждый релиз публикует артефакты в ТРИ места:
|
||||||
|
1. Тег vX.Y.Z (исторический архив + changelog)
|
||||||
|
2. Тег update-channel (фиксированный URL для auto-updater)
|
||||||
|
3. Bridge-теги, указанные в -BridgeTags (для миграции пользователей со
|
||||||
|
старых версий, у которых запечён старый publish.url).
|
||||||
|
|
||||||
|
После того как все пользователи получили версию с новым (фиксированным)
|
||||||
|
publish.url, аргумент -BridgeTags можно перестать указывать.
|
||||||
|
|
||||||
.PARAMETER Bump
|
.PARAMETER Bump
|
||||||
Какую часть semver инкрементировать: patch (по умолчанию), minor, major.
|
Какую часть semver инкрементировать: patch (по умолчанию), minor, major.
|
||||||
Альтернатива — указать -Version явно.
|
|
||||||
|
|
||||||
.PARAMETER Version
|
.PARAMETER Version
|
||||||
Точная версия (напр. "0.3.0"). Если задана, -Bump игнорируется.
|
Точная версия (напр. "0.5.1"). Если задана, -Bump игнорируется.
|
||||||
|
|
||||||
.PARAMETER SkipBuild
|
.PARAMETER SkipBuild
|
||||||
Пропустить сборку (если уже собрано вручную, .exe лежит в release/).
|
Пропустить сборку (если уже собрано вручную, .exe лежит в release/).
|
||||||
|
|
||||||
|
.PARAMETER BridgeTags
|
||||||
|
Список старых тегов, в которые нужно ТАКЖЕ перезалить новые артефакты,
|
||||||
|
чтобы пользователи на этих версиях нашли апдейт. Например: v0.4.0,v0.5.0.
|
||||||
|
|
||||||
.PARAMETER DryRun
|
.PARAMETER DryRun
|
||||||
Показать что произойдёт, но ничего не делать.
|
Показать что произойдёт, ничего не делая.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
pwsh scripts/release.ps1 -Bump minor
|
pwsh scripts/release.ps1 -Bump patch
|
||||||
pwsh scripts/release.ps1 -Version 0.3.0
|
pwsh scripts/release.ps1 -Version 0.5.1 -BridgeTags v0.4.0,v0.5.0
|
||||||
pwsh scripts/release.ps1 -Bump patch -DryRun
|
|
||||||
|
|
||||||
.NOTES
|
.NOTES
|
||||||
Требует переменную окружения GITEA_TOKEN с правом write:repository
|
Требует GITEA_TOKEN с правом write:repository.
|
||||||
(создаётся в Gitea: Settings → Applications → Generate New Token).
|
Канал 'update-channel' должен существовать на Gitea (создаётся однократно).
|
||||||
#>
|
#>
|
||||||
param(
|
param(
|
||||||
[ValidateSet('patch', 'minor', 'major')]
|
[ValidateSet('patch', 'minor', 'major')]
|
||||||
[string]$Bump = 'patch',
|
[string]$Bump = 'patch',
|
||||||
[string]$Version,
|
[string]$Version,
|
||||||
[switch]$SkipBuild,
|
[switch]$SkipBuild,
|
||||||
|
[string[]]$BridgeTags = @(),
|
||||||
[switch]$DryRun
|
[switch]$DryRun
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
# --- Config ---------------------------------------------------------------
|
|
||||||
$repoOwner = 'AnRil'
|
$repoOwner = 'AnRil'
|
||||||
$repoName = 'laude'
|
$repoName = 'laude'
|
||||||
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git'
|
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git'
|
||||||
$apiBase = "https://$giteaHost/api/v1"
|
$channelTag = 'update-channel'
|
||||||
|
|
||||||
# --- Pre-flight checks ---------------------------------------------------
|
# --- Pre-flight ----------------------------------------------------------
|
||||||
$root = Resolve-Path (Join-Path $PSScriptRoot '..')
|
$root = Resolve-Path (Join-Path $PSScriptRoot '..')
|
||||||
Set-Location $root
|
Set-Location $root
|
||||||
|
|
||||||
if (-not $env:GITEA_TOKEN -and -not $DryRun) {
|
if (-not $env:GITEA_TOKEN -and -not $DryRun) {
|
||||||
Write-Error 'GITEA_TOKEN не задан. Создай в Gitea Settings → Applications и export GITEA_TOKEN=...'
|
Write-Error 'GITEA_TOKEN not set.'
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
$status = git status --porcelain
|
$status = git status --porcelain
|
||||||
if ($status) {
|
if ($status) {
|
||||||
Write-Error "Есть незакоммиченные изменения. Сначала закоммить или stash."
|
Write-Error "Uncommitted changes. Commit or stash first."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
$branch = git rev-parse --abbrev-ref HEAD
|
$branch = git rev-parse --abbrev-ref HEAD
|
||||||
if ($branch -ne 'main') {
|
if ($branch -ne 'main') {
|
||||||
Write-Warning "Текущая ветка не main, а $branch. Продолжить? (Ctrl+C для отмены)"
|
Write-Warning "Branch is $branch, not main. Press Enter to continue or Ctrl+C to cancel."
|
||||||
Read-Host 'Press Enter'
|
Read-Host
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Compute next version ------------------------------------------------
|
# --- Compute next version ------------------------------------------------
|
||||||
@@ -83,109 +94,75 @@ if ($Version) {
|
|||||||
|
|
||||||
$tag = "v$next"
|
$tag = "v$next"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "→ Release plan" -ForegroundColor Cyan
|
Write-Host "Release plan" -ForegroundColor Cyan
|
||||||
Write-Host " current : v$current"
|
Write-Host " current : v$current"
|
||||||
Write-Host " next : $tag"
|
Write-Host " next : $tag"
|
||||||
Write-Host " bump : $Bump"
|
Write-Host " publish into : $tag, $channelTag$(if ($BridgeTags) { ', ' + ($BridgeTags -join ', ') })"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
if ($DryRun) {
|
if ($DryRun) {
|
||||||
Write-Host '(dry run — exiting)' -ForegroundColor Yellow
|
Write-Host '(dry run - exiting)' -ForegroundColor Yellow
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Bump version in package.json ---------------------------------------
|
# --- Bump package.json --------------------------------------------------
|
||||||
Write-Host "→ Bumping package.json to $next…" -ForegroundColor Cyan
|
# IMPORTANT: read+write as UTF-8 WITHOUT BOM. PS5.1 defaults will (a) read the
|
||||||
$pkgJson = (Get-Content package.json -Raw) -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`""
|
# file as CP1251 and mangle non-ASCII chars like em-dash, and (b) write back
|
||||||
Set-Content -Path package.json -Value $pkgJson -NoNewline -Encoding utf8
|
# with a BOM that breaks PostCSS / electron-builder reads of package.json.
|
||||||
|
Write-Host "Bumping package.json to $next..." -ForegroundColor Cyan
|
||||||
|
$pkgPath = Join-Path $root 'package.json'
|
||||||
|
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||||
|
$pkgJson = [System.IO.File]::ReadAllText($pkgPath, $utf8NoBom)
|
||||||
|
$pkgJson = $pkgJson -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`""
|
||||||
|
[System.IO.File]::WriteAllText($pkgPath, $pkgJson, $utf8NoBom)
|
||||||
|
|
||||||
git add package.json
|
git add package.json
|
||||||
git commit -m "chore(release): $tag"
|
git commit -m "chore(release): $tag"
|
||||||
|
|
||||||
# --- Build (typecheck + tests + dist) ------------------------------------
|
# --- Quality gates ------------------------------------------------------
|
||||||
if (-not $SkipBuild) {
|
if (-not $SkipBuild) {
|
||||||
Write-Host "→ Running typecheck…" -ForegroundColor Cyan
|
Write-Host "Typecheck..." -ForegroundColor Cyan
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
Write-Host "→ Running tests…" -ForegroundColor Cyan
|
Write-Host "Tests..." -ForegroundColor Cyan
|
||||||
npm run test:run
|
npm run test:run
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
Write-Host "→ Building installer (npm run dist)…" -ForegroundColor Cyan
|
Write-Host "Building installer..." -ForegroundColor Cyan
|
||||||
npm run dist
|
npm run dist
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Verify artifacts exist ---------------------------------------------
|
# --- Verify artifacts ---------------------------------------------------
|
||||||
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$next.exe"
|
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$next.exe"
|
||||||
$blockmap = "$installer.blockmap"
|
$blockmap = "$installer.blockmap"
|
||||||
$manifest = Join-Path 'release' 'latest.yml'
|
$manifest = Join-Path 'release' 'latest.yml'
|
||||||
foreach ($f in @($installer, $blockmap, $manifest)) {
|
foreach ($f in @($installer, $blockmap, $manifest)) {
|
||||||
if (-not (Test-Path $f)) {
|
if (-not (Test-Path $f)) {
|
||||||
Write-Error "Не найден артефакт: $f"
|
Write-Error "Artifact missing: $f"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Tag + push ----------------------------------------------------------
|
# --- Tag + push ---------------------------------------------------------
|
||||||
Write-Host "→ Tagging $tag and pushing…" -ForegroundColor Cyan
|
Write-Host "Tagging $tag and pushing..." -ForegroundColor Cyan
|
||||||
git tag -a $tag -m "Release $tag"
|
git tag -a $tag -m "Release $tag"
|
||||||
git push origin main
|
git push origin main
|
||||||
git push origin $tag
|
git push origin $tag
|
||||||
|
|
||||||
# --- Create release via Gitea API ----------------------------------------
|
# --- Upload to all target releases --------------------------------------
|
||||||
Write-Host "→ Creating Gitea release $tag…" -ForegroundColor Cyan
|
$uploadScript = Join-Path $PSScriptRoot 'upload-release-assets.ps1'
|
||||||
$headers = @{
|
|
||||||
Authorization = "token $env:GITEA_TOKEN"
|
|
||||||
Accept = 'application/json'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Release notes from commits since previous tag
|
$targets = @($tag, $channelTag) + $BridgeTags
|
||||||
$prev = git describe --tags --abbrev=0 "$tag^" 2>$null
|
foreach ($target in $targets) {
|
||||||
if ($prev) {
|
Write-Host ""
|
||||||
$log = git log --pretty=format:"- %s" "$prev..$tag" | Out-String
|
Write-Host "==> Uploading $next artifacts into release '$target'" -ForegroundColor Cyan
|
||||||
} else {
|
& powershell -ExecutionPolicy Bypass -File $uploadScript -Tag $target -AssetVersion $next
|
||||||
$log = git log --pretty=format:"- %s" "$tag" | Out-String
|
if ($LASTEXITCODE -ne 0) {
|
||||||
}
|
Write-Error "Upload to '$target' failed (exit $LASTEXITCODE)"
|
||||||
$body = @"
|
exit $LASTEXITCODE
|
||||||
### Изменения
|
}
|
||||||
|
|
||||||
$log
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Установщик ниже** — запустить и следовать мастеру. Если приложение уже стояло — обновится поверх, настройки сохранятся.
|
|
||||||
"@
|
|
||||||
|
|
||||||
$releaseBody = @{
|
|
||||||
tag_name = $tag
|
|
||||||
name = "Exercise Reminder $tag"
|
|
||||||
body = $body
|
|
||||||
draft = $false
|
|
||||||
prerelease = $false
|
|
||||||
} | ConvertTo-Json -Depth 5
|
|
||||||
|
|
||||||
$release = Invoke-RestMethod `
|
|
||||||
-Uri "$apiBase/repos/$repoOwner/$repoName/releases" `
|
|
||||||
-Method Post `
|
|
||||||
-Headers $headers `
|
|
||||||
-Body $releaseBody `
|
|
||||||
-ContentType 'application/json'
|
|
||||||
|
|
||||||
Write-Host " Release id: $($release.id)" -ForegroundColor DarkGray
|
|
||||||
|
|
||||||
# --- Upload assets -------------------------------------------------------
|
|
||||||
foreach ($asset in @($installer, $blockmap, $manifest)) {
|
|
||||||
$name = Split-Path $asset -Leaf
|
|
||||||
Write-Host "→ Uploading $name…" -ForegroundColor Cyan
|
|
||||||
$uri = "$apiBase/repos/$repoOwner/$repoName/releases/$($release.id)/assets?name=$([uri]::EscapeDataString($name))"
|
|
||||||
Invoke-RestMethod `
|
|
||||||
-Uri $uri `
|
|
||||||
-Method Post `
|
|
||||||
-Headers $headers `
|
|
||||||
-InFile $asset `
|
|
||||||
-ContentType 'application/octet-stream' | Out-Null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$releaseUrl = "https://$giteaHost/$repoOwner/$repoName/releases/tag/$tag"
|
$releaseUrl = "https://$giteaHost/$repoOwner/$repoName/releases/tag/$tag"
|
||||||
@@ -193,4 +170,4 @@ Write-Host ""
|
|||||||
Write-Host "Release published" -ForegroundColor Green
|
Write-Host "Release published" -ForegroundColor Green
|
||||||
Write-Host " $releaseUrl"
|
Write-Host " $releaseUrl"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Auto-updater подхватит обновление на установленных копиях в течение ~6 часов."
|
Write-Host "Auto-updater will pick up the new version within ~1 hour on all installed copies."
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Upload pre-built NSIS artifacts to an existing Gitea release.
|
Upload pre-built NSIS artifacts to a Gitea release.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Use when the tag v* is already pushed (e.g. release.ps1 succeeded up to
|
Uploads installer + blockmap + latest.yml to the release identified by -Tag.
|
||||||
push but failed on upload, or release was created manually without assets).
|
If the release does not exist it is created (only for semver-looking tags;
|
||||||
If a release for the tag does not exist yet, it is created. If it exists,
|
for non-semver tags like 'update-channel' the release must exist already).
|
||||||
same-name assets are replaced.
|
Same-named existing assets are replaced.
|
||||||
|
|
||||||
.PARAMETER Tag
|
.PARAMETER Tag
|
||||||
Version tag, e.g. v0.3.0. Defaults to v<package.json version>.
|
Release tag to upload INTO. May be a version tag (v0.5.1) or a channel
|
||||||
|
tag (update-channel). Defaults to v<package.json version>.
|
||||||
|
|
||||||
|
.PARAMETER AssetVersion
|
||||||
|
Version of the artifacts being uploaded (e.g. 0.5.1). Defaults to the
|
||||||
|
numeric part of -Tag. Specify explicitly when uploading version-X.Y.Z
|
||||||
|
artifacts into a non-version tag (channel or bridge).
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
pwsh scripts/upload-release-assets.ps1
|
pwsh scripts/upload-release-assets.ps1
|
||||||
pwsh scripts/upload-release-assets.ps1 -Tag v0.3.0
|
pwsh scripts/upload-release-assets.ps1 -Tag v0.5.0
|
||||||
|
pwsh scripts/upload-release-assets.ps1 -Tag update-channel -AssetVersion 0.5.1
|
||||||
|
pwsh scripts/upload-release-assets.ps1 -Tag v0.4.0 -AssetVersion 0.5.1
|
||||||
#>
|
#>
|
||||||
param(
|
param(
|
||||||
[string]$Tag
|
[string]$Tag,
|
||||||
|
[string]$AssetVersion
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
@@ -35,10 +44,18 @@ $root = Resolve-Path (Join-Path $PSScriptRoot '..')
|
|||||||
Set-Location $root
|
Set-Location $root
|
||||||
|
|
||||||
if (-not $Tag) {
|
if (-not $Tag) {
|
||||||
$version = (Get-Content package.json | ConvertFrom-Json).version
|
$pkgVersion = (Get-Content package.json | ConvertFrom-Json).version
|
||||||
$Tag = "v$version"
|
$Tag = "v$pkgVersion"
|
||||||
}
|
}
|
||||||
$version = $Tag.TrimStart('v')
|
if (-not $AssetVersion) {
|
||||||
|
# Derive from tag when possible (vX.Y.Z → X.Y.Z); otherwise read package.json.
|
||||||
|
if ($Tag -match '^v\d+\.\d+\.\d+') {
|
||||||
|
$AssetVersion = $Tag.TrimStart('v')
|
||||||
|
} else {
|
||||||
|
$AssetVersion = (Get-Content package.json | ConvertFrom-Json).version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$version = $AssetVersion
|
||||||
|
|
||||||
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$version.exe"
|
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$version.exe"
|
||||||
$blockmap = "$installer.blockmap"
|
$blockmap = "$installer.blockmap"
|
||||||
@@ -66,6 +83,10 @@ try {
|
|||||||
Write-Host " Found release id=$($release.id)" -ForegroundColor DarkGray
|
Write-Host " Found release id=$($release.id)" -ForegroundColor DarkGray
|
||||||
} catch {
|
} catch {
|
||||||
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
|
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
|
||||||
|
if ($Tag -notmatch '^v\d+\.\d+\.\d+') {
|
||||||
|
Write-Error "Release '$Tag' not found and tag is not semver. Create it manually on Gitea (e.g. 'update-channel' is a one-time setup)."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
Write-Host " Not found, creating new release..." -ForegroundColor DarkGray
|
Write-Host " Not found, creating new release..." -ForegroundColor DarkGray
|
||||||
|
|
||||||
$prev = $null
|
$prev = $null
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ async function onMatchEnd(gameId: GameId, payload: MatchEndPayload): Promise<voi
|
|||||||
exerciseName: ch.exerciseName,
|
exerciseName: ch.exerciseName,
|
||||||
reps,
|
reps,
|
||||||
statValue,
|
statValue,
|
||||||
statLabel: STAT_LABELS[ch.stat]
|
statLabel: STAT_LABELS[ch.stat],
|
||||||
|
stat: ch.stat
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (results.length === 0) return
|
if (results.length === 0) return
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import type { Challenge, Exercise, GameId, Settings } from '@shared/types'
|
|||||||
import {
|
import {
|
||||||
addChallenge,
|
addChallenge,
|
||||||
addExercise,
|
addExercise,
|
||||||
|
clearHistory,
|
||||||
deleteChallenge,
|
deleteChallenge,
|
||||||
deleteExercise,
|
deleteExercise,
|
||||||
|
getHistory,
|
||||||
getState,
|
getState,
|
||||||
markDone,
|
markDone,
|
||||||
setGameEnabled,
|
setGameEnabled,
|
||||||
@@ -73,11 +75,14 @@ export function registerIpc(): void {
|
|||||||
return ex
|
return ex
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IPC.markDone, (_e, id: string) => {
|
ipcMain.handle(
|
||||||
const ex = markDone(id)
|
IPC.markDone,
|
||||||
|
(_e, id: string, actualReps?: number) => {
|
||||||
|
const ex = markDone(id, actualReps)
|
||||||
broadcastState()
|
broadcastState()
|
||||||
return ex
|
return ex
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => {
|
ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => {
|
||||||
const ex = snooze(id, minutes)
|
const ex = snooze(id, minutes)
|
||||||
@@ -213,4 +218,10 @@ export function registerIpc(): void {
|
|||||||
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
|
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
|
||||||
ipcMain.handle(IPC.updaterDownload, () => downloadUpdate())
|
ipcMain.handle(IPC.updaterDownload, () => downloadUpdate())
|
||||||
ipcMain.handle(IPC.updaterInstall, () => quitAndInstall())
|
ipcMain.handle(IPC.updaterInstall, () => quitAndInstall())
|
||||||
|
|
||||||
|
// History
|
||||||
|
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
|
||||||
|
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) =>
|
||||||
|
clearHistory(beforeTs)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { powerMonitor, BrowserWindow } from 'electron'
|
import { powerMonitor, BrowserWindow } from 'electron'
|
||||||
import { IPC } from '@shared/ipc'
|
import { IPC } from '@shared/ipc'
|
||||||
import type { Tick } from '@shared/types'
|
import type { Tick } from '@shared/types'
|
||||||
|
import { isQuietAt } from '@shared/types'
|
||||||
import { getExercises, getSettings, updateExercise } from './store'
|
import { getExercises, getSettings, updateExercise } from './store'
|
||||||
import { fireReminder } from './notifications'
|
import { fireReminder } from './notifications'
|
||||||
|
|
||||||
@@ -15,12 +16,15 @@ function checkDueExercises(): void {
|
|||||||
const settings = getSettings()
|
const settings = getSettings()
|
||||||
if (!settings.globalEnabled) return
|
if (!settings.globalEnabled) return
|
||||||
|
|
||||||
|
// Inside the quiet window: defer all due fires to the next minute boundary.
|
||||||
|
// The next tick after the window closes will pick them up.
|
||||||
|
if (isQuietAt(settings.quietHours, new Date())) return
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const exercises = getExercises()
|
const exercises = getExercises()
|
||||||
for (const ex of exercises) {
|
for (const ex of exercises) {
|
||||||
if (!ex.enabled) continue
|
if (!ex.enabled) continue
|
||||||
if (ex.nextFireAt <= now) {
|
if (ex.nextFireAt <= now) {
|
||||||
// Fire once, reschedule from now (drop missed intervals).
|
|
||||||
const updated = updateExercise(ex.id, {
|
const updated = updateExercise(ex.id, {
|
||||||
nextFireAt: now + ex.intervalMinutes * 60_000
|
nextFireAt: now + ex.intervalMinutes * 60_000
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ import {
|
|||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
Exercise,
|
Exercise,
|
||||||
GameId,
|
GameId,
|
||||||
|
HistoryAction,
|
||||||
|
HistoryEntry,
|
||||||
SAMPLE_EXERCISES,
|
SAMPLE_EXERCISES,
|
||||||
Settings
|
Settings
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
|
|
||||||
|
/** Keep at most this many entries (~3 years if ~10/day). Trim oldest. */
|
||||||
|
const HISTORY_MAX = 10_000
|
||||||
|
|
||||||
let cache: AppState | null = null
|
let cache: AppState | null = null
|
||||||
let storePath = ''
|
let storePath = ''
|
||||||
let pendingWrite: NodeJS.Timeout | null = null
|
let pendingWrite: NodeJS.Timeout | null = null
|
||||||
@@ -56,7 +61,8 @@ function makeInitial(): AppState {
|
|||||||
enabled: false
|
enabled: false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
gamesEnabled: {}
|
gamesEnabled: {},
|
||||||
|
history: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +80,49 @@ function load(): AppState {
|
|||||||
exercises: parsed.exercises ?? [],
|
exercises: parsed.exercises ?? [],
|
||||||
settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) },
|
settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) },
|
||||||
challenges: parsed.challenges ?? [],
|
challenges: parsed.challenges ?? [],
|
||||||
gamesEnabled: parsed.gamesEnabled ?? {}
|
gamesEnabled: parsed.gamesEnabled ?? {},
|
||||||
|
history: parsed.history ?? []
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return makeInitial()
|
return makeInitial()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendHistory(
|
||||||
|
exerciseId: string,
|
||||||
|
action: HistoryAction,
|
||||||
|
actualReps?: number
|
||||||
|
): void {
|
||||||
|
const state = getState()
|
||||||
|
if (!state.history) state.history = []
|
||||||
|
const entry: HistoryEntry = { ts: Date.now(), exerciseId, action }
|
||||||
|
if (actualReps !== undefined) entry.actualReps = actualReps
|
||||||
|
state.history.push(entry)
|
||||||
|
// Cap size — trim oldest 10% when over limit, so we don't trim every write.
|
||||||
|
if (state.history.length > HISTORY_MAX) {
|
||||||
|
state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9))
|
||||||
|
}
|
||||||
|
scheduleWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistory(sinceMs?: number): HistoryEntry[] {
|
||||||
|
const all = getState().history ?? []
|
||||||
|
if (sinceMs == null) return all
|
||||||
|
return all.filter((e) => e.ts >= sinceMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHistory(beforeTs?: number): number {
|
||||||
|
const state = getState()
|
||||||
|
const before = state.history?.length ?? 0
|
||||||
|
if (beforeTs == null) {
|
||||||
|
state.history = []
|
||||||
|
} else {
|
||||||
|
state.history = (state.history ?? []).filter((e) => e.ts >= beforeTs)
|
||||||
|
}
|
||||||
|
scheduleWrite()
|
||||||
|
return before - (state.history?.length ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
function flush(): void {
|
function flush(): void {
|
||||||
if (!cache) return
|
if (!cache) return
|
||||||
writeFileSync(getStorePath(), JSON.stringify(cache, null, 2), 'utf-8')
|
writeFileSync(getStorePath(), JSON.stringify(cache, null, 2), 'utf-8')
|
||||||
@@ -155,12 +197,16 @@ export function deleteExercise(id: string): boolean {
|
|||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markDone(id: string): Exercise | undefined {
|
export function markDone(
|
||||||
|
id: string,
|
||||||
|
actualReps?: number
|
||||||
|
): Exercise | undefined {
|
||||||
const state = getState()
|
const state = getState()
|
||||||
const ex = state.exercises.find((e) => e.id === id)
|
const ex = state.exercises.find((e) => e.id === id)
|
||||||
if (!ex) return undefined
|
if (!ex) return undefined
|
||||||
ex.lastDoneAt = Date.now()
|
ex.lastDoneAt = Date.now()
|
||||||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||||
|
appendHistory(id, 'done', actualReps)
|
||||||
scheduleWrite()
|
scheduleWrite()
|
||||||
return ex
|
return ex
|
||||||
}
|
}
|
||||||
@@ -170,6 +216,7 @@ export function snooze(id: string, minutes: number): Exercise | undefined {
|
|||||||
const ex = state.exercises.find((e) => e.id === id)
|
const ex = state.exercises.find((e) => e.id === id)
|
||||||
if (!ex) return undefined
|
if (!ex) return undefined
|
||||||
ex.nextFireAt = Date.now() + minutes * 60_000
|
ex.nextFireAt = Date.now() + minutes * 60_000
|
||||||
|
appendHistory(id, 'snooze')
|
||||||
scheduleWrite()
|
scheduleWrite()
|
||||||
return ex
|
return ex
|
||||||
}
|
}
|
||||||
@@ -179,6 +226,7 @@ export function skip(id: string): Exercise | undefined {
|
|||||||
const ex = state.exercises.find((e) => e.id === id)
|
const ex = state.exercises.find((e) => e.id === id)
|
||||||
if (!ex) return undefined
|
if (!ex) return undefined
|
||||||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||||
|
appendHistory(id, 'skip')
|
||||||
scheduleWrite()
|
scheduleWrite()
|
||||||
return ex
|
return ex
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,31 @@ import { IPC } from '@shared/ipc'
|
|||||||
import type { UpdaterStatus } from '@shared/types'
|
import type { UpdaterStatus } from '@shared/types'
|
||||||
|
|
||||||
let currentStatus: UpdaterStatus = { kind: 'idle' }
|
let currentStatus: UpdaterStatus = { kind: 'idle' }
|
||||||
|
let lastCheckedAt: number | undefined
|
||||||
let wired = false
|
let wired = false
|
||||||
let checkInterval: NodeJS.Timeout | null = null
|
let checkInterval: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000 // every 6 hours
|
// User-initiated checks surface errors. Background checks stay quiet to avoid
|
||||||
|
// the red banner on transient network blips (504s, DNS, captive portals).
|
||||||
|
let silentMode = false
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 60 * 60 * 1000 // every hour
|
||||||
|
const BOOT_DELAY_MS = 5_000
|
||||||
|
// Boot retry: if the first check fails (e.g. network not yet up), retry a few
|
||||||
|
// times with exponential backoff before giving up until the hourly tick.
|
||||||
|
const BOOT_RETRY_DELAYS = [30_000, 120_000, 300_000] // 30s, 2min, 5min
|
||||||
|
|
||||||
export function getUpdaterStatus(): UpdaterStatus {
|
export function getUpdaterStatus(): UpdaterStatus {
|
||||||
return currentStatus
|
return currentStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(s: UpdaterStatus): void {
|
function setStatus(s: UpdaterStatus): void {
|
||||||
|
// Preserve lastCheckedAt across status transitions where applicable.
|
||||||
|
if (s.kind === 'not-available' || s.kind === 'idle') {
|
||||||
|
if (lastCheckedAt && !('lastCheckedAt' in s)) {
|
||||||
|
;(s as { lastCheckedAt?: number }).lastCheckedAt = lastCheckedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
currentStatus = s
|
currentStatus = s
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
if (!win.isDestroyed()) win.webContents.send(IPC.evtUpdaterStatus, s)
|
if (!win.isDestroyed()) win.webContents.send(IPC.evtUpdaterStatus, s)
|
||||||
@@ -38,9 +53,14 @@ export function initUpdater(): void {
|
|||||||
autoUpdater.autoInstallOnAppQuit = true
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
autoUpdater.allowDowngrade = false
|
autoUpdater.allowDowngrade = false
|
||||||
|
|
||||||
autoUpdater.on('checking-for-update', () => setStatus({ kind: 'checking' }))
|
autoUpdater.on('checking-for-update', () => {
|
||||||
|
// Don't replace the prior status with "checking" during silent polls — the
|
||||||
|
// UI would briefly flicker for users opening Settings during a tick.
|
||||||
|
if (!silentMode) setStatus({ kind: 'checking' })
|
||||||
|
})
|
||||||
|
|
||||||
autoUpdater.on('update-available', (info) => {
|
autoUpdater.on('update-available', (info) => {
|
||||||
|
lastCheckedAt = Date.now()
|
||||||
setStatus({
|
setStatus({
|
||||||
kind: 'available',
|
kind: 'available',
|
||||||
version: info.version,
|
version: info.version,
|
||||||
@@ -50,7 +70,12 @@ export function initUpdater(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
autoUpdater.on('update-not-available', () => {
|
autoUpdater.on('update-not-available', () => {
|
||||||
setStatus({ kind: 'not-available', currentVersion: app.getVersion() })
|
lastCheckedAt = Date.now()
|
||||||
|
setStatus({
|
||||||
|
kind: 'not-available',
|
||||||
|
currentVersion: app.getVersion(),
|
||||||
|
lastCheckedAt
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
autoUpdater.on('download-progress', (p) => {
|
autoUpdater.on('download-progress', (p) => {
|
||||||
@@ -68,23 +93,43 @@ export function initUpdater(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
autoUpdater.on('error', (err) => {
|
autoUpdater.on('error', (err) => {
|
||||||
setStatus({
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
kind: 'error',
|
if (silentMode) {
|
||||||
message: err instanceof Error ? err.message : String(err)
|
// Background check failed — keep previous status, don't show red banner.
|
||||||
})
|
// Will retry on the next hourly tick.
|
||||||
|
console.warn('[updater] silent check failed:', message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus({ kind: 'error', message })
|
||||||
})
|
})
|
||||||
|
|
||||||
// First check on boot (slight delay so window has time to subscribe).
|
// First check on boot with retry-on-failure.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void checkForUpdates()
|
void bootCheckWithRetry()
|
||||||
}, 5_000)
|
}, BOOT_DELAY_MS)
|
||||||
|
|
||||||
// Periodic re-check
|
// Periodic re-check (silent).
|
||||||
checkInterval = setInterval(() => {
|
checkInterval = setInterval(() => {
|
||||||
void checkForUpdates()
|
void checkForUpdates({ silent: true })
|
||||||
}, CHECK_INTERVAL_MS)
|
}, CHECK_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bootCheckWithRetry(): Promise<void> {
|
||||||
|
for (let attempt = 0; attempt <= BOOT_RETRY_DELAYS.length; attempt++) {
|
||||||
|
await checkForUpdates({ silent: true })
|
||||||
|
if (
|
||||||
|
currentStatus.kind === 'available' ||
|
||||||
|
currentStatus.kind === 'not-available' ||
|
||||||
|
currentStatus.kind === 'downloaded'
|
||||||
|
) {
|
||||||
|
return // success
|
||||||
|
}
|
||||||
|
const delay = BOOT_RETRY_DELAYS[attempt]
|
||||||
|
if (delay === undefined) return // exhausted retries
|
||||||
|
await new Promise((r) => setTimeout(r, delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function stopUpdater(): void {
|
export function stopUpdater(): void {
|
||||||
if (checkInterval) {
|
if (checkInterval) {
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval)
|
||||||
@@ -92,15 +137,22 @@ export function stopUpdater(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkForUpdates(): Promise<UpdaterStatus> {
|
export async function checkForUpdates(
|
||||||
|
opts: { silent?: boolean } = {}
|
||||||
|
): Promise<UpdaterStatus> {
|
||||||
if (!app.isPackaged) return currentStatus
|
if (!app.isPackaged) return currentStatus
|
||||||
|
silentMode = opts.silent ?? false
|
||||||
try {
|
try {
|
||||||
await autoUpdater.checkForUpdates()
|
await autoUpdater.checkForUpdates()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus({
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
kind: 'error',
|
if (silentMode) {
|
||||||
message: err instanceof Error ? err.message : String(err)
|
console.warn('[updater] silent check failed (sync):', message)
|
||||||
})
|
} else {
|
||||||
|
setStatus({ kind: 'error', message })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
silentMode = false
|
||||||
}
|
}
|
||||||
return currentStatus
|
return currentStatus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
Exercise,
|
Exercise,
|
||||||
GameId,
|
GameId,
|
||||||
GameStatus,
|
GameStatus,
|
||||||
|
HistoryEntry,
|
||||||
MatchSummary,
|
MatchSummary,
|
||||||
Settings,
|
Settings,
|
||||||
Tick,
|
Tick,
|
||||||
@@ -33,7 +34,8 @@ const api = {
|
|||||||
ipcRenderer.invoke(IPC.deleteExercise, id),
|
ipcRenderer.invoke(IPC.deleteExercise, id),
|
||||||
toggleExercise: (id: string, enabled: boolean): Promise<Exercise> =>
|
toggleExercise: (id: string, enabled: boolean): Promise<Exercise> =>
|
||||||
ipcRenderer.invoke(IPC.toggleExercise, id, enabled),
|
ipcRenderer.invoke(IPC.toggleExercise, id, enabled),
|
||||||
markDone: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.markDone, id),
|
markDone: (id: string, actualReps?: number): Promise<Exercise> =>
|
||||||
|
ipcRenderer.invoke(IPC.markDone, id, actualReps),
|
||||||
snooze: (id: string, minutes: number): Promise<Exercise> =>
|
snooze: (id: string, minutes: number): Promise<Exercise> =>
|
||||||
ipcRenderer.invoke(IPC.snooze, id, minutes),
|
ipcRenderer.invoke(IPC.snooze, id, minutes),
|
||||||
skip: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.skip, id),
|
skip: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.skip, id),
|
||||||
@@ -87,6 +89,12 @@ const api = {
|
|||||||
updaterDownload: (): Promise<void> => ipcRenderer.invoke(IPC.updaterDownload),
|
updaterDownload: (): Promise<void> => ipcRenderer.invoke(IPC.updaterDownload),
|
||||||
updaterInstall: (): Promise<void> => ipcRenderer.invoke(IPC.updaterInstall),
|
updaterInstall: (): Promise<void> => ipcRenderer.invoke(IPC.updaterInstall),
|
||||||
|
|
||||||
|
// History
|
||||||
|
getHistory: (sinceMs?: number): Promise<HistoryEntry[]> =>
|
||||||
|
ipcRenderer.invoke(IPC.getHistory, sinceMs),
|
||||||
|
clearHistory: (beforeTs?: number): Promise<number> =>
|
||||||
|
ipcRenderer.invoke(IPC.clearHistory, beforeTs),
|
||||||
|
|
||||||
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
|
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
|
||||||
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
|
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
|
||||||
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),
|
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ export default function App(): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<div className="h-screen w-screen flex flex-col bg-bg">
|
<div className="h-screen w-screen flex flex-col bg-bg">
|
||||||
<Titlebar
|
<Titlebar onMenuClick={() => setMobileNavOpen(true)} />
|
||||||
title="Exercise Reminder"
|
|
||||||
onMenuClick={() => setMobileNavOpen(true)}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
mobileOpen={mobileNavOpen}
|
mobileOpen={mobileNavOpen}
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Check, Clock, X, Trophy, Frown, Gamepad2 } from 'lucide-react'
|
import {
|
||||||
|
Check,
|
||||||
|
Clock,
|
||||||
|
X,
|
||||||
|
Trophy,
|
||||||
|
Frown,
|
||||||
|
Gamepad2,
|
||||||
|
Minus,
|
||||||
|
Plus
|
||||||
|
} from 'lucide-react'
|
||||||
import type {
|
import type {
|
||||||
Exercise,
|
Exercise,
|
||||||
MatchSummary,
|
MatchSummary,
|
||||||
Settings,
|
Settings,
|
||||||
ChallengeResult
|
ChallengeResult,
|
||||||
|
Language
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
|
import { statLabel } from '@shared/types'
|
||||||
import { Icon } from './lib/icon'
|
import { Icon } from './lib/icon'
|
||||||
import { formatInterval } from './lib/format'
|
import { formatInterval } from './lib/format'
|
||||||
|
import { translate, translateN } from './i18n'
|
||||||
|
|
||||||
type Mode =
|
type Mode =
|
||||||
| { kind: 'idle' }
|
| { kind: 'idle' }
|
||||||
@@ -42,7 +54,6 @@ export default function ReminderApp(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Keyboard shortcuts (iOS-like Enter to confirm)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode.kind !== 'exercise') return
|
if (mode.kind !== 'exercise') return
|
||||||
const ex = mode.exercise
|
const ex = mode.exercise
|
||||||
@@ -67,12 +78,15 @@ export default function ReminderApp(): JSX.Element {
|
|||||||
window.api.reminderClose()
|
window.api.reminderClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lang: Language = settings?.language ?? 'ru'
|
||||||
|
|
||||||
if (mode.kind === 'idle') return <div className="reminder-shell" />
|
if (mode.kind === 'idle') return <div className="reminder-shell" />
|
||||||
if (mode.kind === 'exercise') {
|
if (mode.kind === 'exercise') {
|
||||||
return (
|
return (
|
||||||
<ExerciseReminder
|
<ExerciseReminder
|
||||||
exercise={mode.exercise}
|
exercise={mode.exercise}
|
||||||
snoozeMinutes={settings?.snoozeMinutes ?? 5}
|
snoozeMinutes={settings?.snoozeMinutes ?? 5}
|
||||||
|
lang={lang}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -81,6 +95,7 @@ export default function ReminderApp(): JSX.Element {
|
|||||||
<MatchSummaryView
|
<MatchSummaryView
|
||||||
summary={mode.summary}
|
summary={mode.summary}
|
||||||
done={mode.done}
|
done={mode.done}
|
||||||
|
lang={lang}
|
||||||
onMarkDone={(id) =>
|
onMarkDone={(id) =>
|
||||||
setMode({
|
setMode({
|
||||||
kind: 'match',
|
kind: 'match',
|
||||||
@@ -96,14 +111,27 @@ export default function ReminderApp(): JSX.Element {
|
|||||||
function ExerciseReminder({
|
function ExerciseReminder({
|
||||||
exercise,
|
exercise,
|
||||||
snoozeMinutes,
|
snoozeMinutes,
|
||||||
|
lang,
|
||||||
onClose
|
onClose
|
||||||
}: {
|
}: {
|
||||||
exercise: Exercise
|
exercise: Exercise
|
||||||
snoozeMinutes: number
|
snoozeMinutes: number
|
||||||
|
lang: Language
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
|
const t = (key: string, vars?: Record<string, string | number>): string =>
|
||||||
|
translate(lang, key, vars)
|
||||||
|
|
||||||
|
const [actualReps, setActualReps] = useState(exercise.reps)
|
||||||
|
const adjusted = actualReps !== exercise.reps
|
||||||
|
|
||||||
async function done(): Promise<void> {
|
async function done(): Promise<void> {
|
||||||
await window.api.markDone(exercise.id)
|
// Only pass actualReps when user adjusted — otherwise leave undefined
|
||||||
|
// so history records the full planned value cleanly.
|
||||||
|
await window.api.markDone(
|
||||||
|
exercise.id,
|
||||||
|
adjusted ? actualReps : undefined
|
||||||
|
)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
async function snooze(): Promise<void> {
|
async function snooze(): Promise<void> {
|
||||||
@@ -114,6 +142,8 @@ function ExerciseReminder({
|
|||||||
await window.api.skip(exercise.id)
|
await window.api.skip(exercise.id)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
const dec = (): void => setActualReps((n) => Math.max(0, n - 1))
|
||||||
|
const inc = (): void => setActualReps((n) => n + 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reminder-shell flex flex-col h-full">
|
<div className="reminder-shell flex flex-col h-full">
|
||||||
@@ -121,7 +151,7 @@ function ExerciseReminder({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-destructive hover:text-white text-text/45 active:scale-90 transition-all"
|
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-destructive hover:text-white text-text/45 active:scale-90 transition-all"
|
||||||
aria-label="Закрыть"
|
aria-label={t('btn.close')}
|
||||||
>
|
>
|
||||||
<X size={13} strokeWidth={2.5} />
|
<X size={13} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
@@ -140,45 +170,76 @@ function ExerciseReminder({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="text-[13px] uppercase tracking-[0.18em] text-accent font-bold">
|
<div className="text-[13px] uppercase tracking-[0.18em] text-accent font-bold">
|
||||||
Время тренировки
|
{t('reminder.kicker')}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-serif text-[30px] leading-tight tracking-tight mt-2 mb-3 font-bold">
|
<h1 className="font-serif text-[30px] leading-tight tracking-tight mt-2 mb-3 font-bold">
|
||||||
{exercise.name}
|
{exercise.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="inline-flex items-baseline gap-2 font-mono-num">
|
{/* Reps stepper — tap +/− if you did less than planned. */}
|
||||||
<span className="text-[56px] font-semibold tracking-tight text-text leading-none">
|
<div className="inline-flex items-center gap-3 select-none">
|
||||||
{exercise.reps}
|
<button
|
||||||
|
onClick={dec}
|
||||||
|
className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all"
|
||||||
|
aria-label="−"
|
||||||
|
>
|
||||||
|
<Minus size={16} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<div className="inline-flex items-baseline gap-2 font-mono-num min-w-[120px] justify-center">
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
'text-[56px] font-semibold tracking-tight leading-none',
|
||||||
|
adjusted ? 'text-accent' : 'text-text'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{actualReps}
|
||||||
|
</span>
|
||||||
|
<span className="text-[15px] text-text/65 font-semibold">
|
||||||
|
{t('reminder.reps')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[15px] text-text/65 font-semibold">раз</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={inc}
|
||||||
|
className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all"
|
||||||
|
aria-label="+"
|
||||||
|
>
|
||||||
|
<Plus size={16} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{adjusted && (
|
||||||
|
<div className="text-[12px] text-accent mt-2 font-medium">
|
||||||
|
{t('reminder.partial', { actual: actualReps, planned: exercise.reps })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="text-[13px] text-text/65 mt-4 inline-flex items-center gap-1.5 font-medium">
|
<div className="text-[13px] text-text/65 mt-4 inline-flex items-center gap-1.5 font-medium">
|
||||||
<Clock size={12} strokeWidth={2.4} />
|
<Clock size={12} strokeWidth={2.4} />
|
||||||
Следующее через {formatInterval(exercise.intervalMinutes)}
|
{t('reminder.next_in', {
|
||||||
|
interval: formatInterval(exercise.intervalMinutes, lang)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* iOS action sheet — buttons stacked vertically, equal width */}
|
|
||||||
<div className="px-4 pb-4 space-y-2">
|
<div className="px-4 pb-4 space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={done}
|
onClick={done}
|
||||||
className="w-full h-12 rounded-2xl bg-accent text-white text-[16px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
className="w-full h-12 rounded-2xl bg-accent text-white text-[16px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||||
>
|
>
|
||||||
<Check size={17} strokeWidth={2.5} /> Готово
|
<Check size={17} strokeWidth={2.5} /> {t('reminder.btn.done')}
|
||||||
</button>
|
</button>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={snooze}
|
onClick={snooze}
|
||||||
className="h-11 rounded-2xl bg-surface-2 text-text text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
className="h-11 rounded-2xl bg-surface-2 text-text text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||||
>
|
>
|
||||||
<Clock size={15} strokeWidth={2.5} /> {snoozeMinutes} мин
|
<Clock size={15} strokeWidth={2.5} />{' '}
|
||||||
|
{t('btn.snooze_min', { n: snoozeMinutes })}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={skip}
|
onClick={skip}
|
||||||
className="h-11 rounded-2xl bg-surface-2 text-text/65 text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
className="h-11 rounded-2xl bg-surface-2 text-text/65 text-[15px] font-semibold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||||
>
|
>
|
||||||
Пропустить
|
{t('btn.skip')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,14 +250,24 @@ function ExerciseReminder({
|
|||||||
function MatchSummaryView({
|
function MatchSummaryView({
|
||||||
summary,
|
summary,
|
||||||
done,
|
done,
|
||||||
|
lang,
|
||||||
onMarkDone,
|
onMarkDone,
|
||||||
onClose
|
onClose
|
||||||
}: {
|
}: {
|
||||||
summary: MatchSummary
|
summary: MatchSummary
|
||||||
done: Set<string>
|
done: Set<string>
|
||||||
|
lang: Language
|
||||||
onMarkDone: (id: string) => void
|
onMarkDone: (id: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
|
const t = (key: string, vars?: Record<string, string | number>): string =>
|
||||||
|
translate(lang, key, vars)
|
||||||
|
const tn = (
|
||||||
|
base: string,
|
||||||
|
n: number,
|
||||||
|
vars?: Record<string, string | number>
|
||||||
|
): string => translateN(lang, base, n, vars)
|
||||||
|
|
||||||
const allDone = summary.results.every((r) => done.has(r.challengeId))
|
const allDone = summary.results.every((r) => done.has(r.challengeId))
|
||||||
const totalReps = summary.results.reduce((s, r) => s + r.reps, 0)
|
const totalReps = summary.results.reduce((s, r) => s + r.reps, 0)
|
||||||
const remainingReps = summary.results
|
const remainingReps = summary.results
|
||||||
@@ -204,6 +275,7 @@ function MatchSummaryView({
|
|||||||
.reduce((s, r) => s + r.reps, 0)
|
.reduce((s, r) => s + r.reps, 0)
|
||||||
const won = summary.won === true
|
const won = summary.won === true
|
||||||
const lost = summary.won === false
|
const lost = summary.won === false
|
||||||
|
const minutes = Math.floor(summary.durationMs / 60_000)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="reminder-shell flex flex-col h-full">
|
<div className="reminder-shell flex flex-col h-full">
|
||||||
@@ -214,7 +286,7 @@ function MatchSummaryView({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-destructive hover:text-white text-text/45 active:scale-90 transition-all"
|
className="titlebar-nodrag w-7 h-6 grid place-items-center rounded-md hover:bg-destructive hover:text-white text-text/45 active:scale-90 transition-all"
|
||||||
aria-label="Закрыть"
|
aria-label={t('btn.close')}
|
||||||
>
|
>
|
||||||
<X size={13} strokeWidth={2.5} />
|
<X size={13} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
@@ -239,19 +311,23 @@ function MatchSummaryView({
|
|||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h1 className="font-serif text-[26px] tracking-tight font-bold">
|
<h1 className="font-serif text-[26px] tracking-tight font-bold">
|
||||||
{won ? 'Победа' : lost ? 'Поражение' : 'Матч завершён'}
|
{won
|
||||||
|
? t('match.title.won')
|
||||||
|
: lost
|
||||||
|
? t('match.title.lost')
|
||||||
|
: t('match.title.draw')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[13px] text-text/65 mt-1.5 font-medium">
|
<p className="text-[13px] text-text/65 mt-1.5 font-medium">
|
||||||
<span className="font-mono-num font-bold text-text">
|
<span className="font-mono-num font-bold text-text">{minutes}</span>{' '}
|
||||||
{Math.floor(summary.durationMs / 60_000)}
|
{t('fmt.m')} ·{' '}
|
||||||
</span>{' '}
|
{tn('match.summary.challenges', summary.results.length)}{' · '}
|
||||||
мин · {summary.results.length} челлендж
|
|
||||||
{summary.results.length === 1 ? '' : 'а'} ·{' '}
|
|
||||||
{allDone ? (
|
{allDone ? (
|
||||||
<span className="text-success font-bold">всё готово</span>
|
<span className="text-success font-bold">
|
||||||
|
{t('match.summary.all_done')}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-accent font-mono-num font-bold">
|
<span className="text-accent font-mono-num font-bold">
|
||||||
{remainingReps} осталось
|
{t('match.summary.remaining', { n: remainingReps })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -262,6 +338,7 @@ function MatchSummaryView({
|
|||||||
<ChallengeRow
|
<ChallengeRow
|
||||||
key={r.challengeId}
|
key={r.challengeId}
|
||||||
result={r}
|
result={r}
|
||||||
|
lang={lang}
|
||||||
done={done.has(r.challengeId)}
|
done={done.has(r.challengeId)}
|
||||||
onMarkDone={() => onMarkDone(r.challengeId)}
|
onMarkDone={() => onMarkDone(r.challengeId)}
|
||||||
/>
|
/>
|
||||||
@@ -270,11 +347,11 @@ function MatchSummaryView({
|
|||||||
|
|
||||||
<div className="px-4 pb-4 pt-3 flex items-center gap-3">
|
<div className="px-4 pb-4 pt-3 flex items-center gap-3">
|
||||||
<div className="flex-1 text-[13px] text-text/65 font-medium">
|
<div className="flex-1 text-[13px] text-text/65 font-medium">
|
||||||
Всего ·{' '}
|
{t('match.total')} ·{' '}
|
||||||
<span className="text-text font-mono-num font-bold text-[16px]">
|
<span className="text-text font-mono-num font-bold text-[16px]">
|
||||||
{totalReps}
|
{totalReps}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
повторов
|
{t('match.total_reps_suffix')}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -285,10 +362,10 @@ function MatchSummaryView({
|
|||||||
>
|
>
|
||||||
{allDone ? (
|
{allDone ? (
|
||||||
<>
|
<>
|
||||||
<Check size={14} strokeWidth={2.5} /> Закрыть
|
<Check size={14} strokeWidth={2.5} /> {t('btn.close')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Позже'
|
t('btn.later')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,13 +375,16 @@ function MatchSummaryView({
|
|||||||
|
|
||||||
function ChallengeRow({
|
function ChallengeRow({
|
||||||
result,
|
result,
|
||||||
|
lang,
|
||||||
done,
|
done,
|
||||||
onMarkDone
|
onMarkDone
|
||||||
}: {
|
}: {
|
||||||
result: ChallengeResult
|
result: ChallengeResult
|
||||||
|
lang: Language
|
||||||
done: boolean
|
done: boolean
|
||||||
onMarkDone: () => void
|
onMarkDone: () => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
|
const label = result.stat ? statLabel(result.stat, lang) : result.statLabel
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
layout
|
layout
|
||||||
@@ -336,7 +416,7 @@ function ChallengeRow({
|
|||||||
<span className="font-mono-num font-bold text-text">
|
<span className="font-mono-num font-bold text-text">
|
||||||
{result.statValue}
|
{result.statValue}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
{result.statLabel} → <span>{result.name}</span>
|
{label} → <span>{result.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -356,7 +436,6 @@ function ChallengeRow({
|
|||||||
? 'bg-success text-white cursor-default'
|
? 'bg-success text-white cursor-default'
|
||||||
: 'bg-accent text-white active:scale-90'
|
: 'bg-accent text-white active:scale-90'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
aria-label="Готово"
|
|
||||||
>
|
>
|
||||||
<Check size={15} strokeWidth={2.5} />
|
<Check size={15} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Exercise, Tick } from '@shared/types'
|
|||||||
import { Icon } from '../lib/icon'
|
import { Icon } from '../lib/icon'
|
||||||
import { formatCountdown, formatInterval } from '../lib/format'
|
import { formatCountdown, formatInterval } from '../lib/format'
|
||||||
import { Switch } from './ui/Switch'
|
import { Switch } from './ui/Switch'
|
||||||
|
import { useT } from '../i18n'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
exercise: Exercise
|
exercise: Exercise
|
||||||
@@ -34,6 +35,7 @@ export function ExerciseCard({
|
|||||||
const elapsedPct = total > 0 ? 1 - remaining / total : 0
|
const elapsedPct = total > 0 ? 1 - remaining / total : 0
|
||||||
const isDue = ms <= 0 && exercise.enabled
|
const isDue = ms <= 0 && exercise.enabled
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const { t, lang } = useT()
|
||||||
|
|
||||||
// Ring math
|
// Ring math
|
||||||
const R = 22
|
const R = 22
|
||||||
@@ -104,7 +106,7 @@ export function ExerciseCard({
|
|||||||
<button
|
<button
|
||||||
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="Меню"
|
aria-label={t('titlebar.menu_aria')}
|
||||||
>
|
>
|
||||||
<MoreHorizontal size={16} />
|
<MoreHorizontal size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -122,7 +124,7 @@ export function ExerciseCard({
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 text-[13px] hover:bg-surface-2 active:bg-hairline/25"
|
className="w-full text-left px-3 py-2 text-[13px] hover:bg-surface-2 active:bg-hairline/25"
|
||||||
>
|
>
|
||||||
Редактировать
|
{t('btn.edit')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -131,7 +133,7 @@ export function ExerciseCard({
|
|||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 text-[13px] text-destructive hover:bg-destructive/10 active:bg-destructive/15"
|
className="w-full text-left px-3 py-2 text-[13px] text-destructive hover:bg-destructive/10 active:bg-destructive/15"
|
||||||
>
|
>
|
||||||
Удалить
|
{t('btn.delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -139,14 +141,17 @@ export function ExerciseCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[14px] text-text/65 mt-1 font-medium">
|
<div className="text-[14px] text-text/65 mt-1 font-medium">
|
||||||
{exercise.reps} раз · каждые {formatInterval(exercise.intervalMinutes)}
|
{t('editor.exercise.preview.meta', {
|
||||||
|
reps: exercise.reps,
|
||||||
|
min: exercise.intervalMinutes
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Countdown + switch */}
|
{/* Countdown + switch */}
|
||||||
<div className="flex items-end justify-between mt-3.5">
|
<div className="flex items-end justify-between mt-3.5">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold">
|
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold">
|
||||||
{isDue ? 'Сейчас' : 'Через'}
|
{isDue ? t('dashboard.stat.next.now') : t('fmt.through')}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
@@ -154,19 +159,20 @@ export function ExerciseCard({
|
|||||||
isDue ? 'text-accent' : 'text-text'
|
isDue ? 'text-accent' : 'text-text'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{exercise.enabled ? formatCountdown(ms) : 'на паузе'}
|
{exercise.enabled
|
||||||
|
? formatCountdown(ms, lang)
|
||||||
|
: t('fmt.paused')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={exercise.enabled}
|
checked={exercise.enabled}
|
||||||
onChange={onToggle}
|
onChange={onToggle}
|
||||||
aria-label="Включить/выключить"
|
aria-label={t('btn.done')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Done action — appears as filled pill at bottom only on due */}
|
|
||||||
{isDue && (
|
{isDue && (
|
||||||
<motion.button
|
<motion.button
|
||||||
initial={{ opacity: 0, y: 4 }}
|
initial={{ opacity: 0, y: 4 }}
|
||||||
@@ -174,7 +180,7 @@ export function ExerciseCard({
|
|||||||
onClick={onMarkDone}
|
onClick={onMarkDone}
|
||||||
className="mt-4 w-full h-11 rounded-xl bg-accent text-white text-[15px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
className="mt-4 w-full h-11 rounded-xl bg-accent text-white text-[15px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||||
>
|
>
|
||||||
<Check size={15} strokeWidth={2.5} /> Готово
|
<Check size={15} strokeWidth={2.5} /> {t('btn.done')}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Exercise } from '@shared/types'
|
|||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
import { ICON_CHOICES, Icon } from '../lib/icon'
|
import { ICON_CHOICES, Icon } from '../lib/icon'
|
||||||
|
import { useT } from '../i18n'
|
||||||
|
|
||||||
type Draft = {
|
type Draft = {
|
||||||
name: string
|
name: string
|
||||||
@@ -34,6 +35,7 @@ export function ExerciseEditor({
|
|||||||
onSave
|
onSave
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
const [draft, setDraft] = useState<Draft>(EMPTY)
|
const [draft, setDraft] = useState<Draft>(EMPTY)
|
||||||
|
const { t } = useT()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (exercise) {
|
if (exercise) {
|
||||||
@@ -55,46 +57,52 @@ export function ExerciseEditor({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={exercise ? 'Редактировать' : 'Новое упражнение'}
|
title={
|
||||||
|
exercise
|
||||||
|
? t('editor.exercise.title.edit')
|
||||||
|
: t('editor.exercise.title.new')
|
||||||
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="plain" onClick={onClose}>
|
<Button variant="plain" onClick={onClose}>
|
||||||
Отмена
|
{t('btn.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!canSave} onClick={() => onSave(draft)}>
|
<Button disabled={!canSave} onClick={() => onSave(draft)}>
|
||||||
Сохранить
|
{t('btn.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Live preview header */}
|
|
||||||
<div className="rounded-2xl bg-surface-2 p-4 flex items-center gap-4">
|
<div className="rounded-2xl bg-surface-2 p-4 flex items-center gap-4">
|
||||||
<div className="w-14 h-14 rounded-2xl bg-accent text-white grid place-items-center shrink-0">
|
<div className="w-14 h-14 rounded-2xl bg-accent text-white grid place-items-center shrink-0">
|
||||||
<Icon name={draft.icon} size={26} strokeWidth={2.2} />
|
<Icon name={draft.icon} size={26} strokeWidth={2.2} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-display text-[18px] font-semibold tracking-tight truncate">
|
<div className="font-display text-[18px] font-semibold tracking-tight truncate">
|
||||||
{draft.name || 'Без названия'}
|
{draft.name || t('editor.exercise.preview.placeholder')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[13px] text-text/55 mt-0.5 font-mono-num">
|
<div className="text-[13px] text-text/55 mt-0.5 font-mono-num">
|
||||||
{draft.reps} раз · каждые {draft.intervalMinutes} мин
|
{t('editor.exercise.preview.meta', {
|
||||||
|
reps: draft.reps,
|
||||||
|
min: draft.intervalMinutes
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Название">
|
<Field label={t('editor.field.name')}>
|
||||||
<input
|
<input
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
placeholder="Приседания"
|
placeholder={t('editor.field.name.placeholder')}
|
||||||
className="ios-input"
|
className="ios-input"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Повторений">
|
<Field label={t('editor.field.reps')}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
@@ -108,7 +116,7 @@ export function ExerciseEditor({
|
|||||||
className="ios-input font-mono-num"
|
className="ios-input font-mono-num"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Интервал (мин)">
|
<Field label={t('editor.field.interval_min')}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
@@ -124,7 +132,7 @@ export function ExerciseEditor({
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Иконка">
|
<Field label={t('editor.field.icon')}>
|
||||||
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
|
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
|
||||||
{ICON_CHOICES.map((name) => (
|
{ICON_CHOICES.map((name) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
174
src/renderer/src/components/HistoryHeatmap.tsx
Normal file
174
src/renderer/src/components/HistoryHeatmap.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { dailyRepsRange } from '../lib/history'
|
||||||
|
import type { Exercise, HistoryEntry, Language } from '@shared/types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
history: HistoryEntry[]
|
||||||
|
exercises: Exercise[]
|
||||||
|
days?: number
|
||||||
|
lang: Language
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub-style contribution grid: weeks as columns, days-of-week as rows.
|
||||||
|
* Intensity bucket from 0 to 4 based on relative reps within the window.
|
||||||
|
*/
|
||||||
|
export function HistoryHeatmap({
|
||||||
|
history,
|
||||||
|
exercises,
|
||||||
|
days = 84, // 12 weeks
|
||||||
|
lang
|
||||||
|
}: Props): JSX.Element {
|
||||||
|
const cells = useMemo(
|
||||||
|
() => dailyRepsRange(history, exercises, days),
|
||||||
|
[history, exercises, days]
|
||||||
|
)
|
||||||
|
const max = cells.reduce((m, c) => Math.max(m, c.reps), 0)
|
||||||
|
|
||||||
|
// Bucket function — 0 for zero, 1-4 for low/med/high/peak.
|
||||||
|
function bucket(n: number): number {
|
||||||
|
if (n === 0) return 0
|
||||||
|
if (max === 0) return 0
|
||||||
|
const ratio = n / max
|
||||||
|
if (ratio < 0.25) return 1
|
||||||
|
if (ratio < 0.5) return 2
|
||||||
|
if (ratio < 0.85) return 3
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group cells into columns (weeks). Pad start so first column aligns to
|
||||||
|
// its actual week (Mon-first).
|
||||||
|
const firstDay = cells[0]?.date ?? new Date()
|
||||||
|
const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon
|
||||||
|
const padded: ({
|
||||||
|
key: string
|
||||||
|
date: Date
|
||||||
|
reps: number
|
||||||
|
} | null)[] = [...Array(firstWeekday).fill(null), ...cells]
|
||||||
|
const weeks: (typeof padded)[] = []
|
||||||
|
for (let i = 0; i < padded.length; i += 7) {
|
||||||
|
weeks.push(padded.slice(i, i + 7))
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayLabels =
|
||||||
|
lang === 'en'
|
||||||
|
? ['Mon', '', 'Wed', '', 'Fri', '', 'Sun']
|
||||||
|
: ['Пн', '', 'Ср', '', 'Пт', '', 'Вс']
|
||||||
|
|
||||||
|
const monthLabels = useMemo(() => {
|
||||||
|
const fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', {
|
||||||
|
month: 'short'
|
||||||
|
})
|
||||||
|
return weeks.map((w) => {
|
||||||
|
const first = w.find((c) => c !== null)
|
||||||
|
return first ? fmt.format(first.date) : ''
|
||||||
|
})
|
||||||
|
}, [weeks, lang])
|
||||||
|
|
||||||
|
// Compress repeated month labels (only show on first week of the month)
|
||||||
|
const monthLabelsCompressed = monthLabels.map((label, i) =>
|
||||||
|
label && label !== monthLabels[i - 1] ? label : ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const dateFmt = useMemo(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
}),
|
||||||
|
[lang]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="text-[14px] text-text/75 font-semibold">
|
||||||
|
{lang === 'en' ? 'Activity, last 12 weeks' : 'Активность за 12 недель'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
{/* Month labels above grid */}
|
||||||
|
<div className="flex gap-[3px] mb-1 pl-7">
|
||||||
|
{monthLabelsCompressed.map((label, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-[12px] text-[10px] text-text/45 font-medium"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[6px]">
|
||||||
|
<div className="flex flex-col gap-[3px] justify-around pt-0.5">
|
||||||
|
{dayLabels.map((l, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-[12px] text-[10px] text-text/40 font-medium leading-none w-5 text-right"
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[3px]">
|
||||||
|
{weeks.map((w, wi) => (
|
||||||
|
<div key={wi} className="flex flex-col gap-[3px]">
|
||||||
|
{w.map((c, di) => {
|
||||||
|
if (!c) {
|
||||||
|
return (
|
||||||
|
<div key={di} className="w-[12px] h-[12px]" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const b = bucket(c.reps)
|
||||||
|
const tone =
|
||||||
|
b === 0
|
||||||
|
? 'bg-surface-2'
|
||||||
|
: b === 1
|
||||||
|
? 'bg-accent/30'
|
||||||
|
: b === 2
|
||||||
|
? 'bg-accent/55'
|
||||||
|
: b === 3
|
||||||
|
? 'bg-accent/80'
|
||||||
|
: 'bg-accent'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={di}
|
||||||
|
title={`${dateFmt.format(c.date)} · ${c.reps} ${lang === 'en' ? 'reps' : 'повторов'}`}
|
||||||
|
className={[
|
||||||
|
'w-[12px] h-[12px] rounded-[3px] transition-colors',
|
||||||
|
tone
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex items-center justify-end gap-1.5 mt-3 text-[10px] text-text/45 font-medium">
|
||||||
|
<span>{lang === 'en' ? 'Less' : 'Меньше'}</span>
|
||||||
|
{[0, 1, 2, 3, 4].map((b) => (
|
||||||
|
<div
|
||||||
|
key={b}
|
||||||
|
className={[
|
||||||
|
'w-[10px] h-[10px] rounded-[2px]',
|
||||||
|
b === 0
|
||||||
|
? 'bg-surface-2'
|
||||||
|
: b === 1
|
||||||
|
? 'bg-accent/30'
|
||||||
|
: b === 2
|
||||||
|
? 'bg-accent/55'
|
||||||
|
: b === 3
|
||||||
|
? 'bg-accent/80'
|
||||||
|
: 'bg-accent'
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span>{lang === 'en' ? 'More' : 'Больше'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,29 +8,34 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
X
|
X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { useT } from '../i18n'
|
||||||
|
|
||||||
type Item = {
|
type Item = {
|
||||||
to: string
|
to: string
|
||||||
label: string
|
labelKey: string
|
||||||
icon: typeof Sun
|
icon: typeof Sun
|
||||||
end?: boolean
|
end?: boolean
|
||||||
tint?: string
|
tint?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tinted icon plaques á la iOS Settings rows.
|
|
||||||
const items: Item[] = [
|
const items: Item[] = [
|
||||||
{ to: '/', label: 'Сегодня', icon: Sun, end: true, tint: 'bg-accent' },
|
{ to: '/', labelKey: 'nav.today', icon: Sun, end: true, tint: 'bg-accent' },
|
||||||
{
|
{
|
||||||
to: '/exercises',
|
to: '/exercises',
|
||||||
label: 'Упражнения',
|
labelKey: 'nav.exercises',
|
||||||
icon: Dumbbell,
|
icon: Dumbbell,
|
||||||
tint: 'bg-info'
|
tint: 'bg-info'
|
||||||
},
|
},
|
||||||
{ to: '/games', label: 'Игры', icon: Joystick, tint: 'bg-accent-2' },
|
{ to: '/games', labelKey: 'nav.games', icon: Joystick, tint: 'bg-accent-2' },
|
||||||
{ to: '/challenges', label: 'Челленджи', icon: Flame, tint: 'bg-warning' },
|
{
|
||||||
|
to: '/challenges',
|
||||||
|
labelKey: 'nav.challenges',
|
||||||
|
icon: Flame,
|
||||||
|
tint: 'bg-warning'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/settings',
|
to: '/settings',
|
||||||
label: 'Настройки',
|
labelKey: 'nav.settings',
|
||||||
icon: Settings2,
|
icon: Settings2,
|
||||||
tint: 'bg-text/70'
|
tint: 'bg-text/70'
|
||||||
}
|
}
|
||||||
@@ -45,14 +50,13 @@ export function Sidebar({
|
|||||||
mobileOpen = false,
|
mobileOpen = false,
|
||||||
onMobileClose
|
onMobileClose
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
|
const { t } = useT()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop sidebar — macOS vibrancy panel */}
|
|
||||||
<aside className="hidden md:flex w-64 shrink-0 vibrancy hairline-b border-r-0 flex-col">
|
<aside className="hidden md:flex w-64 shrink-0 vibrancy hairline-b border-r-0 flex-col">
|
||||||
<SidebarContent />
|
<SidebarContent />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Mobile drawer */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -79,7 +83,7 @@ export function Sidebar({
|
|||||||
<button
|
<button
|
||||||
onClick={onMobileClose}
|
onClick={onMobileClose}
|
||||||
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 transition-colors active:scale-90"
|
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 transition-colors active:scale-90"
|
||||||
aria-label="Закрыть"
|
aria-label={t('btn.close')}
|
||||||
>
|
>
|
||||||
<X size={14} strokeWidth={2.5} />
|
<X size={14} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
@@ -93,21 +97,20 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
|
function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
|
||||||
|
const { t } = useT()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Brand */}
|
|
||||||
<div className="px-5 pt-7 pb-6">
|
<div className="px-5 pt-7 pb-6">
|
||||||
<div className="font-serif text-[36px] leading-none tracking-tight font-bold">
|
<div className="font-serif text-[36px] leading-none tracking-tight font-bold">
|
||||||
Laude
|
Laude
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[13px] text-text/55 mt-2 font-medium">
|
<div className="text-[13px] text-text/55 mt-2 font-medium">
|
||||||
Двигайся осознанно
|
{t('sidebar.slogan')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
|
||||||
<nav className="px-2.5 flex flex-col gap-1">
|
<nav className="px-2.5 flex flex-col gap-1">
|
||||||
{items.map(({ to, label, icon: Icon, end, tint }) => (
|
{items.map(({ to, labelKey, icon: Icon, end, tint }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
@@ -140,7 +143,7 @@ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
|
|||||||
: 'text-text/85 font-medium'
|
: 'text-text/85 font-medium'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{label}
|
{t(labelKey)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -148,14 +151,13 @@ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Status footer */}
|
|
||||||
<div className="mt-auto px-5 pb-5">
|
<div className="mt-auto px-5 pb-5">
|
||||||
<div className="flex items-center gap-2 text-[11px] text-text/45">
|
<div className="flex items-center gap-2 text-[11px] text-text/45">
|
||||||
<span className="relative flex h-1.5 w-1.5">
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
|
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
|
||||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
|
||||||
</span>
|
</span>
|
||||||
Активность отслеживается
|
{t('sidebar.status_tracking')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,46 +1,49 @@
|
|||||||
import { Minus, X, Square, Menu } from 'lucide-react'
|
import { Minus, X, Square, Menu } from 'lucide-react'
|
||||||
|
import { useT } from '../i18n'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string
|
title?: string
|
||||||
onMenuClick?: () => void
|
onMenuClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* macOS-style translucent titlebar. Title centred small, no app icon.
|
|
||||||
* Window buttons sit right; a left-side hamburger surfaces on mobile only.
|
|
||||||
*/
|
|
||||||
export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
|
export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
|
||||||
|
const { t } = useT()
|
||||||
|
const effectiveTitle = title ?? t('titlebar.app_title')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b">
|
<div className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b">
|
||||||
{/* Left: hamburger only on small */}
|
|
||||||
<div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
|
<div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
|
||||||
{onMenuClick && (
|
{onMenuClick && (
|
||||||
<button
|
<button
|
||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
className="titlebar-nodrag md:hidden w-8 h-7 grid place-items-center rounded-md hover:bg-text/[0.08] text-text/65 transition-colors"
|
className="titlebar-nodrag md:hidden w-8 h-7 grid place-items-center rounded-md hover:bg-text/[0.08] text-text/65 hover:text-text transition-colors"
|
||||||
aria-label="Меню"
|
aria-label={t('titlebar.menu_aria')}
|
||||||
>
|
>
|
||||||
<Menu size={15} strokeWidth={2} />
|
<Menu size={15} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Centre title */}
|
|
||||||
<div className="text-[12px] font-medium text-text/55 truncate px-2">
|
<div className="text-[12px] font-medium text-text/55 truncate px-2">
|
||||||
{title}
|
{effectiveTitle}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right window controls */}
|
|
||||||
<div className="titlebar-nodrag flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
|
<div className="titlebar-nodrag flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
|
||||||
<WinBtn onClick={() => window.api.minimizeMain()} label="Свернуть">
|
<WinBtn
|
||||||
|
onClick={() => window.api.minimizeMain()}
|
||||||
|
label={t('titlebar.minimize_aria')}
|
||||||
|
>
|
||||||
<Minus size={13} strokeWidth={2} />
|
<Minus size={13} strokeWidth={2} />
|
||||||
</WinBtn>
|
</WinBtn>
|
||||||
<WinBtn onClick={() => window.api.hideMain()} label="В трей">
|
<WinBtn
|
||||||
|
onClick={() => window.api.hideMain()}
|
||||||
|
label={t('titlebar.tray_aria')}
|
||||||
|
>
|
||||||
<Square size={11} strokeWidth={2} />
|
<Square size={11} strokeWidth={2} />
|
||||||
</WinBtn>
|
</WinBtn>
|
||||||
<WinBtn
|
<WinBtn
|
||||||
onClick={() => window.api.closeMain()}
|
onClick={() => window.api.closeMain()}
|
||||||
label="Закрыть"
|
label={t('titlebar.close_aria')}
|
||||||
danger
|
danger
|
||||||
>
|
>
|
||||||
<X size={13} strokeWidth={2} />
|
<X size={13} strokeWidth={2} />
|
||||||
|
|||||||
@@ -10,8 +10,18 @@ import {
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
import { Card } from './ui/Card'
|
import { Card } from './ui/Card'
|
||||||
|
import { useT, type TFn } from '../i18n'
|
||||||
import type { UpdaterStatus } from '@shared/types'
|
import type { UpdaterStatus } from '@shared/types'
|
||||||
|
|
||||||
|
function formatChecked(ts: number, t: TFn): string {
|
||||||
|
const diffMs = Date.now() - ts
|
||||||
|
const diffMin = Math.max(0, Math.round(diffMs / 60_000))
|
||||||
|
if (diffMin < 1) return t('updater.checked.just_now')
|
||||||
|
if (diffMin < 60) return t('updater.checked.minutes_ago', { n: diffMin })
|
||||||
|
const diffH = Math.round(diffMin / 60)
|
||||||
|
return t('updater.checked.hours_ago', { n: diffH })
|
||||||
|
}
|
||||||
|
|
||||||
export function UpdaterCard(): JSX.Element {
|
export function UpdaterCard(): JSX.Element {
|
||||||
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
|
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
@@ -67,13 +77,15 @@ function Body({
|
|||||||
onDownload: () => void
|
onDownload: () => void
|
||||||
onInstall: () => void
|
onInstall: () => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
|
const { t } = useT()
|
||||||
|
|
||||||
if (status.kind === 'unsupported') {
|
if (status.kind === 'unsupported') {
|
||||||
return (
|
return (
|
||||||
<Cell
|
<Cell
|
||||||
tone="muted"
|
tone="muted"
|
||||||
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
|
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
|
||||||
title="Auto-update недоступен"
|
title={t('updater.unsupported')}
|
||||||
subtitle={status.reason}
|
subtitle={t('updater.unsupported.reason_dev')}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -81,21 +93,33 @@ function Body({
|
|||||||
return (
|
return (
|
||||||
<Cell
|
<Cell
|
||||||
tone="info"
|
tone="info"
|
||||||
icon={<RefreshCw size={16} strokeWidth={2.4} className="animate-spin" />}
|
icon={
|
||||||
title="Проверяем обновления…"
|
<RefreshCw
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2.4}
|
||||||
|
className="animate-spin"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={t('updater.checking')}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (status.kind === 'not-available') {
|
if (status.kind === 'not-available') {
|
||||||
|
const subtitle = status.lastCheckedAt
|
||||||
|
? t('updater.up_to_date.subtitle_checked', {
|
||||||
|
v: status.currentVersion,
|
||||||
|
when: formatChecked(status.lastCheckedAt, t)
|
||||||
|
})
|
||||||
|
: t('updater.up_to_date.subtitle', { v: status.currentVersion })
|
||||||
return (
|
return (
|
||||||
<Cell
|
<Cell
|
||||||
tone="success"
|
tone="success"
|
||||||
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
|
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
|
||||||
title="Последняя версия"
|
title={t('updater.up_to_date')}
|
||||||
subtitle={`Текущая: v${status.currentVersion}`}
|
subtitle={subtitle}
|
||||||
action={
|
action={
|
||||||
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
|
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
|
||||||
<RefreshCw size={13} strokeWidth={2.5} /> Проверить
|
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -106,15 +130,15 @@ function Body({
|
|||||||
<Cell
|
<Cell
|
||||||
tone="accent"
|
tone="accent"
|
||||||
icon={<Sparkles size={16} strokeWidth={2.4} />}
|
icon={<Sparkles size={16} strokeWidth={2.4} />}
|
||||||
title={`Доступна v${status.version}`}
|
title={t('updater.available.title', { v: status.version })}
|
||||||
subtitle={
|
subtitle={
|
||||||
status.releaseDate
|
status.releaseDate
|
||||||
? new Date(status.releaseDate).toLocaleString('ru-RU')
|
? new Date(status.releaseDate).toLocaleString()
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
<Button size="sm" onClick={onDownload} disabled={busy}>
|
<Button size="sm" onClick={onDownload} disabled={busy}>
|
||||||
<Download size={13} strokeWidth={2.5} /> Скачать
|
<Download size={13} strokeWidth={2.5} /> {t('btn.download')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -131,11 +155,14 @@ function Body({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[15px] font-semibold leading-tight">
|
<div className="text-[15px] font-semibold leading-tight">
|
||||||
Загружаем обновление
|
{t('updater.downloading.title')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[13px] text-text/65 mt-1 font-mono-num font-medium">
|
<div className="text-[13px] text-text/65 mt-1 font-mono-num font-medium">
|
||||||
{mb(status.transferred)} / {mb(status.total)} МБ ·{' '}
|
{t('updater.downloading.subtitle', {
|
||||||
{(status.bytesPerSecond / 1024 / 1024).toFixed(2)} МБ/с
|
got: mb(status.transferred),
|
||||||
|
total: mb(status.total),
|
||||||
|
speed: (status.bytesPerSecond / 1024 / 1024).toFixed(2)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono-num font-bold text-[18px] text-accent">
|
<div className="font-mono-num font-bold text-[18px] text-accent">
|
||||||
@@ -157,11 +184,11 @@ function Body({
|
|||||||
<Cell
|
<Cell
|
||||||
tone="success"
|
tone="success"
|
||||||
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
|
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
|
||||||
title={`Готово · v${status.version}`}
|
title={t('updater.downloaded.title', { v: status.version })}
|
||||||
subtitle="Перезапусти для применения"
|
subtitle={t('updater.downloaded.subtitle')}
|
||||||
action={
|
action={
|
||||||
<Button variant="filled" size="sm" onClick={onInstall}>
|
<Button variant="filled" size="sm" onClick={onInstall}>
|
||||||
Перезапустить
|
{t('btn.restart')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -172,11 +199,11 @@ function Body({
|
|||||||
<Cell
|
<Cell
|
||||||
tone="destructive"
|
tone="destructive"
|
||||||
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
|
icon={<AlertTriangle size={16} strokeWidth={2.4} />}
|
||||||
title="Ошибка проверки"
|
title={t('updater.error.title')}
|
||||||
subtitle={status.message}
|
subtitle={status.message}
|
||||||
action={
|
action={
|
||||||
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
|
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
|
||||||
<RefreshCw size={13} strokeWidth={2.5} /> Повторить
|
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.retry')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -186,11 +213,11 @@ function Body({
|
|||||||
<Cell
|
<Cell
|
||||||
tone="muted"
|
tone="muted"
|
||||||
icon={<PackageCheck size={16} strokeWidth={2.4} />}
|
icon={<PackageCheck size={16} strokeWidth={2.4} />}
|
||||||
title="Проверить обновления"
|
title={t('updater.idle.title')}
|
||||||
subtitle="Авто-проверка раз в 6 часов"
|
subtitle={t('updater.idle.subtitle')}
|
||||||
action={
|
action={
|
||||||
<Button size="sm" onClick={onCheck} disabled={busy}>
|
<Button size="sm" onClick={onCheck} disabled={busy}>
|
||||||
<RefreshCw size={13} strokeWidth={2.5} /> Проверить
|
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
448
src/renderer/src/i18n/dict.ts
Normal file
448
src/renderer/src/i18n/dict.ts
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/**
|
||||||
|
* Flat string dictionary for ru/en. Keys use dot notation but are just
|
||||||
|
* strings — no nesting overhead.
|
||||||
|
*
|
||||||
|
* Interpolation: `{name}` placeholders are replaced via `useT()` helper.
|
||||||
|
*
|
||||||
|
* Pluralization: keys ending in `.one`/`.few`/`.many` (ru) or
|
||||||
|
* `.one`/`.other` (en) are picked by `tn()` helper based on count.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Dict = Record<string, string>
|
||||||
|
|
||||||
|
export const ru: Dict = {
|
||||||
|
// Sidebar / nav
|
||||||
|
'nav.today': 'Сегодня',
|
||||||
|
'nav.exercises': 'Упражнения',
|
||||||
|
'nav.games': 'Игры',
|
||||||
|
'nav.challenges': 'Челленджи',
|
||||||
|
'nav.settings': 'Настройки',
|
||||||
|
'sidebar.slogan': 'Двигайся осознанно',
|
||||||
|
'sidebar.status_tracking': 'Активность отслеживается',
|
||||||
|
'titlebar.menu_aria': 'Меню',
|
||||||
|
'titlebar.minimize_aria': 'Свернуть',
|
||||||
|
'titlebar.tray_aria': 'В трей',
|
||||||
|
'titlebar.close_aria': 'Закрыть',
|
||||||
|
'titlebar.app_title': 'Exercise Reminder',
|
||||||
|
|
||||||
|
// Common buttons / actions
|
||||||
|
'btn.add': 'Добавить',
|
||||||
|
'btn.new': 'Новый',
|
||||||
|
'btn.cancel': 'Отмена',
|
||||||
|
'btn.save': 'Сохранить',
|
||||||
|
'btn.done': 'Готово',
|
||||||
|
'btn.start': 'Старт',
|
||||||
|
'btn.pause': 'Пауза',
|
||||||
|
'btn.refresh': 'Обновить',
|
||||||
|
'btn.edit': 'Редактировать',
|
||||||
|
'btn.delete': 'Удалить',
|
||||||
|
'btn.snooze_min': 'Отложить {n} мин',
|
||||||
|
'btn.skip': 'Пропустить',
|
||||||
|
'btn.close': 'Закрыть',
|
||||||
|
'btn.later': 'Позже',
|
||||||
|
'btn.connect': 'Подключить',
|
||||||
|
'btn.disconnect': 'Отключить',
|
||||||
|
'btn.check': 'Проверить',
|
||||||
|
'btn.download': 'Скачать',
|
||||||
|
'btn.restart': 'Перезапустить',
|
||||||
|
'btn.retry': 'Повторить',
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
'dashboard.kicker': 'Тренировка дня',
|
||||||
|
'dashboard.title': 'Сегодня',
|
||||||
|
'dashboard.stat.active': 'Активных',
|
||||||
|
'dashboard.stat.active.of': 'из {total}',
|
||||||
|
'dashboard.stat.today_done': 'Сегодня',
|
||||||
|
'dashboard.stat.today_done.subtitle': 'повторов за день',
|
||||||
|
'dashboard.stat.streak': 'Стрик',
|
||||||
|
'dashboard.stat.streak.subtitle': '{n} дн. подряд',
|
||||||
|
'dashboard.stat.next': 'До следующего',
|
||||||
|
'dashboard.stat.next.now': 'Сейчас',
|
||||||
|
'dashboard.stat.next.subtitle_paused': 'на паузе',
|
||||||
|
'dashboard.stat.next.subtitle_running': 'отсчёт идёт',
|
||||||
|
'dashboard.stat.tracking': 'Трекинг матчей',
|
||||||
|
'dashboard.stat.tracking.on': 'On',
|
||||||
|
'dashboard.stat.tracking.off': 'Off',
|
||||||
|
'dashboard.stat.tracking.subtitle_on': 'в реальном времени',
|
||||||
|
'dashboard.stat.tracking.subtitle_off': 'выключен',
|
||||||
|
'dashboard.paused.title': 'Напоминания на паузе',
|
||||||
|
'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт',
|
||||||
|
'dashboard.empty.title': 'Программа пуста',
|
||||||
|
'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать',
|
||||||
|
|
||||||
|
// Exercises
|
||||||
|
'exercises.kicker': 'Программа',
|
||||||
|
'exercises.title': 'Упражнения',
|
||||||
|
'exercises.section.active': 'Активные · {n}',
|
||||||
|
'exercises.section.disabled': 'Выключенные · {n}',
|
||||||
|
'exercises.row.meta': '{reps} раз · {interval}',
|
||||||
|
'exercises.empty': 'Программа пуста — добавь первое упражнение',
|
||||||
|
|
||||||
|
// Exercise editor
|
||||||
|
'editor.exercise.title.new': 'Новое упражнение',
|
||||||
|
'editor.exercise.title.edit': 'Редактировать',
|
||||||
|
'editor.exercise.preview.placeholder': 'Без названия',
|
||||||
|
'editor.exercise.preview.meta': '{reps} раз · каждые {min} мин',
|
||||||
|
'editor.field.name': 'Название',
|
||||||
|
'editor.field.name.placeholder': 'Приседания',
|
||||||
|
'editor.field.reps': 'Повторений',
|
||||||
|
'editor.field.interval_min': 'Интервал (мин)',
|
||||||
|
'editor.field.icon': 'Иконка',
|
||||||
|
|
||||||
|
// Challenges
|
||||||
|
'challenges.kicker': 'Правила за матч',
|
||||||
|
'challenges.title': 'Челленджи',
|
||||||
|
'challenges.subtitle': 'Повторов = {formula}',
|
||||||
|
'challenges.subtitle.formula': 'статистика × коэффициент',
|
||||||
|
'challenges.warning.no_games':
|
||||||
|
'Челленджи срабатывают после матча. Подключи игру во вкладке «Игры».',
|
||||||
|
'challenges.section.all': 'Все · {n}',
|
||||||
|
'challenges.empty':
|
||||||
|
'Челленджей пока нет. Привяжи упражнение к статистике матча.',
|
||||||
|
|
||||||
|
// Challenge editor
|
||||||
|
'editor.challenge.title.new': 'Новый челлендж',
|
||||||
|
'editor.challenge.title.edit': 'Редактировать',
|
||||||
|
'editor.field.challenge_name': 'Название',
|
||||||
|
'editor.field.challenge_name.placeholder': 'За смерти — приседания',
|
||||||
|
'editor.field.game': 'Игра',
|
||||||
|
'editor.field.stat': 'Статистика',
|
||||||
|
'editor.field.multiplier': 'Коэффициент',
|
||||||
|
'editor.field.exercise_name': 'Упражнение',
|
||||||
|
'editor.field.exercise_name.placeholder': 'Приседания',
|
||||||
|
'editor.challenge.preview.kicker': 'Превью · 5 событий',
|
||||||
|
'editor.challenge.preview.fallback': 'повторов',
|
||||||
|
|
||||||
|
// Games
|
||||||
|
'games.kicker': 'Трекинг матчей',
|
||||||
|
'games.title': 'Игры',
|
||||||
|
'games.subtitle':
|
||||||
|
'Подключи игру — челленджи сработают сразу после матча',
|
||||||
|
'games.subtitle.live': '{n} live',
|
||||||
|
'games.section.supported': 'Поддерживаемые',
|
||||||
|
'games.scanning': 'Сканируем установленные игры…',
|
||||||
|
'games.queued.body':
|
||||||
|
'Steam запущен. Параметр {opt} пропишется автоматически при следующем закрытии Steam.',
|
||||||
|
'games.no_user.body':
|
||||||
|
'В Steam нет залогиненного аккаунта (нет папки userdata). Запусти Steam один раз и нажми «Установить интеграцию».',
|
||||||
|
'games.not_installed.hint': 'Установи игру в Steam и нажми «Обновить»',
|
||||||
|
'games.dev.toggle': 'dev · симулировать конец матча',
|
||||||
|
'games.badge.live': 'Live',
|
||||||
|
'games.badge.ready': 'Готово',
|
||||||
|
'games.badge.queued': 'В очереди',
|
||||||
|
'games.badge.installed': 'Установлена',
|
||||||
|
'games.badge.not_found': 'Не найдена',
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
'settings.kicker': 'Конфигурация',
|
||||||
|
'settings.title': 'Настройки',
|
||||||
|
'settings.section.reminders': 'Напоминания',
|
||||||
|
'settings.section.quiet': 'Тихие часы',
|
||||||
|
'settings.section.window': 'Окно и трей',
|
||||||
|
'settings.section.appearance': 'Внешний вид',
|
||||||
|
'settings.section.language': 'Язык',
|
||||||
|
'settings.section.updates': 'Обновления',
|
||||||
|
'settings.notification_mode.label': 'Режим уведомления',
|
||||||
|
'settings.notification_mode.hint': 'Как должно выглядеть напоминание',
|
||||||
|
'settings.notification_mode.modal': 'Окно поверх всех',
|
||||||
|
'settings.notification_mode.toast': 'Системное уведомление',
|
||||||
|
'settings.notification_mode.both': 'Окно и уведомление',
|
||||||
|
'settings.sound.label': 'Звук уведомления',
|
||||||
|
'settings.sound.hint': 'Короткий сигнал при срабатывании',
|
||||||
|
'settings.snooze.label': '«Отложить» на',
|
||||||
|
'settings.snooze.hint': 'Сколько минут добавлять при отложении',
|
||||||
|
'settings.snooze.1': '1 минута',
|
||||||
|
'settings.snooze.5': '5 минут',
|
||||||
|
'settings.snooze.10': '10 минут',
|
||||||
|
'settings.snooze.15': '15 минут',
|
||||||
|
'settings.snooze.30': '30 минут',
|
||||||
|
'settings.quiet.enabled.label': 'Тихие часы',
|
||||||
|
'settings.quiet.enabled.hint': 'Не показывать напоминания в указанные часы',
|
||||||
|
'settings.quiet.times.label': 'С и до',
|
||||||
|
'settings.quiet.times.hint': 'Если до раньше — окно переходит через полночь',
|
||||||
|
'settings.quiet.days.label': 'Дни недели',
|
||||||
|
'settings.quiet.days.hint': 'Тихие часы действуют в выбранные дни',
|
||||||
|
'settings.tray.label': 'Сворачивать в трей',
|
||||||
|
'settings.tray.hint': 'При закрытии остаётся работать в фоне',
|
||||||
|
'settings.autostart.label': 'Запускать с Windows',
|
||||||
|
'settings.autostart.hint': 'Открывать при входе в систему',
|
||||||
|
'settings.start_minimized.label': 'Запускать свёрнутым',
|
||||||
|
'settings.start_minimized.hint': 'При автозапуске открывать сразу в трее',
|
||||||
|
'settings.theme.label': 'Тема',
|
||||||
|
'settings.theme.hint': 'Светлая / тёмная / как в системе',
|
||||||
|
'settings.theme.system': 'Как в системе',
|
||||||
|
'settings.theme.light': 'Светлая',
|
||||||
|
'settings.theme.dark': 'Тёмная',
|
||||||
|
'settings.language.label': 'Язык интерфейса',
|
||||||
|
'settings.language.hint': 'Применяется сразу',
|
||||||
|
'settings.language.ru': 'Русский',
|
||||||
|
'settings.language.en': 'English',
|
||||||
|
'settings.loading': 'Загрузка…',
|
||||||
|
|
||||||
|
// Updater
|
||||||
|
'updater.unsupported': 'Auto-update недоступен',
|
||||||
|
'updater.unsupported.reason_dev': 'Auto-update недоступен в dev-режиме',
|
||||||
|
'updater.checking': 'Проверяем обновления…',
|
||||||
|
'updater.up_to_date': 'Последняя версия',
|
||||||
|
'updater.up_to_date.subtitle': 'Текущая: v{v}',
|
||||||
|
'updater.up_to_date.subtitle_checked': 'Текущая: v{v} · проверено {when}',
|
||||||
|
'updater.last_checked': 'проверено {when}',
|
||||||
|
'updater.checked.just_now': 'только что',
|
||||||
|
'updater.checked.minutes_ago': '{n} мин назад',
|
||||||
|
'updater.checked.hours_ago': '{n} ч назад',
|
||||||
|
'updater.available.title': 'Доступна v{v}',
|
||||||
|
'updater.downloading.title': 'Загружаем обновление',
|
||||||
|
'updater.downloading.subtitle': '{got} / {total} МБ · {speed} МБ/с',
|
||||||
|
'updater.downloaded.title': 'Готово · v{v}',
|
||||||
|
'updater.downloaded.subtitle': 'Перезапусти для применения',
|
||||||
|
'updater.error.title': 'Ошибка проверки',
|
||||||
|
'updater.idle.title': 'Проверить обновления',
|
||||||
|
'updater.idle.subtitle': 'Авто-проверка раз в час',
|
||||||
|
|
||||||
|
// Reminder window
|
||||||
|
'reminder.kicker': 'Время тренировки',
|
||||||
|
'reminder.subkicker': 'Двигайся',
|
||||||
|
'reminder.reps': 'раз',
|
||||||
|
'reminder.next_in': 'Следующее через {interval}',
|
||||||
|
'reminder.partial': 'Засчитаем {actual} из {planned}',
|
||||||
|
'reminder.btn.done': 'Готово',
|
||||||
|
'match.title.won': 'Победа',
|
||||||
|
'match.title.lost': 'Поражение',
|
||||||
|
'match.title.draw': 'Матч завершён',
|
||||||
|
'match.summary.minutes_count': '{n} мин',
|
||||||
|
'match.summary.challenges_one': '{n} челлендж',
|
||||||
|
'match.summary.challenges_few': '{n} челленджа',
|
||||||
|
'match.summary.challenges_many': '{n} челленджей',
|
||||||
|
'match.summary.all_done': 'всё готово',
|
||||||
|
'match.summary.remaining': '{n} осталось',
|
||||||
|
'match.total': 'Всего',
|
||||||
|
'match.total_reps_suffix': 'повторов',
|
||||||
|
|
||||||
|
// Format helpers
|
||||||
|
'fmt.now': 'сейчас',
|
||||||
|
'fmt.h': 'ч',
|
||||||
|
'fmt.m': 'мин',
|
||||||
|
'fmt.h_short': 'ч',
|
||||||
|
'fmt.m_short': 'м',
|
||||||
|
'fmt.s_short': 'с',
|
||||||
|
'fmt.paused': 'на паузе',
|
||||||
|
'fmt.through': 'Через'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const en: Dict = {
|
||||||
|
// Sidebar / nav
|
||||||
|
'nav.today': 'Today',
|
||||||
|
'nav.exercises': 'Exercises',
|
||||||
|
'nav.games': 'Games',
|
||||||
|
'nav.challenges': 'Challenges',
|
||||||
|
'nav.settings': 'Settings',
|
||||||
|
'sidebar.slogan': 'Move with intention',
|
||||||
|
'sidebar.status_tracking': 'Activity tracking is on',
|
||||||
|
'titlebar.menu_aria': 'Menu',
|
||||||
|
'titlebar.minimize_aria': 'Minimize',
|
||||||
|
'titlebar.tray_aria': 'To tray',
|
||||||
|
'titlebar.close_aria': 'Close',
|
||||||
|
'titlebar.app_title': 'Exercise Reminder',
|
||||||
|
|
||||||
|
// Common buttons
|
||||||
|
'btn.add': 'Add',
|
||||||
|
'btn.new': 'New',
|
||||||
|
'btn.cancel': 'Cancel',
|
||||||
|
'btn.save': 'Save',
|
||||||
|
'btn.done': 'Done',
|
||||||
|
'btn.start': 'Start',
|
||||||
|
'btn.pause': 'Pause',
|
||||||
|
'btn.refresh': 'Refresh',
|
||||||
|
'btn.edit': 'Edit',
|
||||||
|
'btn.delete': 'Delete',
|
||||||
|
'btn.snooze_min': 'Snooze {n}m',
|
||||||
|
'btn.skip': 'Skip',
|
||||||
|
'btn.close': 'Close',
|
||||||
|
'btn.later': 'Later',
|
||||||
|
'btn.connect': 'Connect',
|
||||||
|
'btn.disconnect': 'Disconnect',
|
||||||
|
'btn.check': 'Check',
|
||||||
|
'btn.download': 'Download',
|
||||||
|
'btn.restart': 'Restart',
|
||||||
|
'btn.retry': 'Retry',
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
'dashboard.kicker': 'Daily training',
|
||||||
|
'dashboard.title': 'Today',
|
||||||
|
'dashboard.stat.active': 'Active',
|
||||||
|
'dashboard.stat.active.of': 'of {total}',
|
||||||
|
'dashboard.stat.today_done': 'Today',
|
||||||
|
'dashboard.stat.today_done.subtitle': 'reps logged',
|
||||||
|
'dashboard.stat.streak': 'Streak',
|
||||||
|
'dashboard.stat.streak.subtitle': '{n} days in a row',
|
||||||
|
'dashboard.stat.next': 'Next in',
|
||||||
|
'dashboard.stat.next.now': 'Now',
|
||||||
|
'dashboard.stat.next.subtitle_paused': 'paused',
|
||||||
|
'dashboard.stat.next.subtitle_running': 'counting down',
|
||||||
|
'dashboard.stat.tracking': 'Match tracking',
|
||||||
|
'dashboard.stat.tracking.on': 'On',
|
||||||
|
'dashboard.stat.tracking.off': 'Off',
|
||||||
|
'dashboard.stat.tracking.subtitle_on': 'real-time',
|
||||||
|
'dashboard.stat.tracking.subtitle_off': 'disabled',
|
||||||
|
'dashboard.paused.title': 'Reminders paused',
|
||||||
|
'dashboard.paused.hint': 'Resume to continue countdown',
|
||||||
|
'dashboard.empty.title': 'Program is empty',
|
||||||
|
'dashboard.empty.hint': 'Add your first exercise to start',
|
||||||
|
|
||||||
|
// Exercises
|
||||||
|
'exercises.kicker': 'Program',
|
||||||
|
'exercises.title': 'Exercises',
|
||||||
|
'exercises.section.active': 'Active · {n}',
|
||||||
|
'exercises.section.disabled': 'Disabled · {n}',
|
||||||
|
'exercises.row.meta': '{reps} reps · {interval}',
|
||||||
|
'exercises.empty': 'Program is empty — add your first exercise',
|
||||||
|
|
||||||
|
// Exercise editor
|
||||||
|
'editor.exercise.title.new': 'New exercise',
|
||||||
|
'editor.exercise.title.edit': 'Edit',
|
||||||
|
'editor.exercise.preview.placeholder': 'Untitled',
|
||||||
|
'editor.exercise.preview.meta': '{reps} reps · every {min} min',
|
||||||
|
'editor.field.name': 'Name',
|
||||||
|
'editor.field.name.placeholder': 'Squats',
|
||||||
|
'editor.field.reps': 'Reps',
|
||||||
|
'editor.field.interval_min': 'Interval (min)',
|
||||||
|
'editor.field.icon': 'Icon',
|
||||||
|
|
||||||
|
// Challenges
|
||||||
|
'challenges.kicker': 'Per-match rules',
|
||||||
|
'challenges.title': 'Challenges',
|
||||||
|
'challenges.subtitle': 'Reps = {formula}',
|
||||||
|
'challenges.subtitle.formula': 'stat × multiplier',
|
||||||
|
'challenges.warning.no_games':
|
||||||
|
'Challenges trigger after a match. Connect a game in the Games tab.',
|
||||||
|
'challenges.section.all': 'All · {n}',
|
||||||
|
'challenges.empty':
|
||||||
|
'No challenges yet. Tie an exercise to a match statistic.',
|
||||||
|
|
||||||
|
// Challenge editor
|
||||||
|
'editor.challenge.title.new': 'New challenge',
|
||||||
|
'editor.challenge.title.edit': 'Edit',
|
||||||
|
'editor.field.challenge_name': 'Name',
|
||||||
|
'editor.field.challenge_name.placeholder': 'Squats per death',
|
||||||
|
'editor.field.game': 'Game',
|
||||||
|
'editor.field.stat': 'Statistic',
|
||||||
|
'editor.field.multiplier': 'Multiplier',
|
||||||
|
'editor.field.exercise_name': 'Exercise',
|
||||||
|
'editor.field.exercise_name.placeholder': 'Squats',
|
||||||
|
'editor.challenge.preview.kicker': 'Preview · 5 events',
|
||||||
|
'editor.challenge.preview.fallback': 'reps',
|
||||||
|
|
||||||
|
// Games
|
||||||
|
'games.kicker': 'Match tracking',
|
||||||
|
'games.title': 'Games',
|
||||||
|
'games.subtitle': 'Connect a game — challenges fire right after the match',
|
||||||
|
'games.subtitle.live': '{n} live',
|
||||||
|
'games.section.supported': 'Supported',
|
||||||
|
'games.scanning': 'Scanning installed games…',
|
||||||
|
'games.queued.body':
|
||||||
|
'Steam is running. The {opt} option will be added automatically next time Steam closes.',
|
||||||
|
'games.no_user.body':
|
||||||
|
'No logged-in Steam account (no userdata folder). Launch Steam once, then click “Connect”.',
|
||||||
|
'games.not_installed.hint': 'Install the game in Steam and click Refresh',
|
||||||
|
'games.dev.toggle': 'dev · simulate match end',
|
||||||
|
'games.badge.live': 'Live',
|
||||||
|
'games.badge.ready': 'Ready',
|
||||||
|
'games.badge.queued': 'Queued',
|
||||||
|
'games.badge.installed': 'Installed',
|
||||||
|
'games.badge.not_found': 'Not found',
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
'settings.kicker': 'Configuration',
|
||||||
|
'settings.title': 'Settings',
|
||||||
|
'settings.section.reminders': 'Reminders',
|
||||||
|
'settings.section.quiet': 'Quiet hours',
|
||||||
|
'settings.section.window': 'Window & tray',
|
||||||
|
'settings.section.appearance': 'Appearance',
|
||||||
|
'settings.section.language': 'Language',
|
||||||
|
'settings.section.updates': 'Updates',
|
||||||
|
'settings.notification_mode.label': 'Notification mode',
|
||||||
|
'settings.notification_mode.hint': 'How a reminder appears',
|
||||||
|
'settings.notification_mode.modal': 'Window on top',
|
||||||
|
'settings.notification_mode.toast': 'System notification',
|
||||||
|
'settings.notification_mode.both': 'Window and notification',
|
||||||
|
'settings.sound.label': 'Notification sound',
|
||||||
|
'settings.sound.hint': 'Short beep on trigger',
|
||||||
|
'settings.snooze.label': '“Snooze” for',
|
||||||
|
'settings.snooze.hint': 'How many minutes to postpone',
|
||||||
|
'settings.snooze.1': '1 minute',
|
||||||
|
'settings.snooze.5': '5 minutes',
|
||||||
|
'settings.snooze.10': '10 minutes',
|
||||||
|
'settings.snooze.15': '15 minutes',
|
||||||
|
'settings.snooze.30': '30 minutes',
|
||||||
|
'settings.quiet.enabled.label': 'Quiet hours',
|
||||||
|
'settings.quiet.enabled.hint': 'Suppress reminders during the chosen window',
|
||||||
|
'settings.quiet.times.label': 'From and to',
|
||||||
|
'settings.quiet.times.hint': 'If `to` is earlier, the window wraps midnight',
|
||||||
|
'settings.quiet.days.label': 'Days of week',
|
||||||
|
'settings.quiet.days.hint': 'Quiet hours apply on the selected days',
|
||||||
|
'settings.tray.label': 'Minimize to tray',
|
||||||
|
'settings.tray.hint': 'Keep running in background when closed',
|
||||||
|
'settings.autostart.label': 'Start with Windows',
|
||||||
|
'settings.autostart.hint': 'Open at system login',
|
||||||
|
'settings.start_minimized.label': 'Start minimized',
|
||||||
|
'settings.start_minimized.hint': 'On autostart open straight to tray',
|
||||||
|
'settings.theme.label': 'Theme',
|
||||||
|
'settings.theme.hint': 'Light / dark / follow system',
|
||||||
|
'settings.theme.system': 'System',
|
||||||
|
'settings.theme.light': 'Light',
|
||||||
|
'settings.theme.dark': 'Dark',
|
||||||
|
'settings.language.label': 'Interface language',
|
||||||
|
'settings.language.hint': 'Applied immediately',
|
||||||
|
'settings.language.ru': 'Русский',
|
||||||
|
'settings.language.en': 'English',
|
||||||
|
'settings.loading': 'Loading…',
|
||||||
|
|
||||||
|
// Updater
|
||||||
|
'updater.unsupported': 'Auto-update unavailable',
|
||||||
|
'updater.unsupported.reason_dev': 'Auto-update is disabled in dev mode',
|
||||||
|
'updater.checking': 'Checking for updates…',
|
||||||
|
'updater.up_to_date': 'Up to date',
|
||||||
|
'updater.up_to_date.subtitle': 'Current: v{v}',
|
||||||
|
'updater.up_to_date.subtitle_checked': 'Current: v{v} · checked {when}',
|
||||||
|
'updater.last_checked': 'checked {when}',
|
||||||
|
'updater.checked.just_now': 'just now',
|
||||||
|
'updater.checked.minutes_ago': '{n}m ago',
|
||||||
|
'updater.checked.hours_ago': '{n}h ago',
|
||||||
|
'updater.available.title': 'v{v} available',
|
||||||
|
'updater.downloading.title': 'Downloading update',
|
||||||
|
'updater.downloading.subtitle': '{got} / {total} MB · {speed} MB/s',
|
||||||
|
'updater.downloaded.title': 'Ready · v{v}',
|
||||||
|
'updater.downloaded.subtitle': 'Restart to apply',
|
||||||
|
'updater.error.title': 'Check failed',
|
||||||
|
'updater.idle.title': 'Check for updates',
|
||||||
|
'updater.idle.subtitle': 'Auto-check every hour',
|
||||||
|
|
||||||
|
// Reminder window
|
||||||
|
'reminder.kicker': 'Workout time',
|
||||||
|
'reminder.subkicker': 'Move',
|
||||||
|
'reminder.reps': 'reps',
|
||||||
|
'reminder.next_in': 'Next in {interval}',
|
||||||
|
'reminder.partial': "We'll log {actual} of {planned}",
|
||||||
|
'reminder.btn.done': 'Done',
|
||||||
|
'match.title.won': 'Victory',
|
||||||
|
'match.title.lost': 'Defeat',
|
||||||
|
'match.title.draw': 'Match finished',
|
||||||
|
'match.summary.minutes_count': '{n} min',
|
||||||
|
'match.summary.challenges_one': '{n} challenge',
|
||||||
|
'match.summary.challenges_few': '{n} challenges',
|
||||||
|
'match.summary.challenges_many': '{n} challenges',
|
||||||
|
'match.summary.all_done': 'all done',
|
||||||
|
'match.summary.remaining': '{n} left',
|
||||||
|
'match.total': 'Total',
|
||||||
|
'match.total_reps_suffix': 'reps',
|
||||||
|
|
||||||
|
// Format helpers
|
||||||
|
'fmt.now': 'now',
|
||||||
|
'fmt.h': 'h',
|
||||||
|
'fmt.m': 'min',
|
||||||
|
'fmt.h_short': 'h',
|
||||||
|
'fmt.m_short': 'm',
|
||||||
|
'fmt.s_short': 's',
|
||||||
|
'fmt.paused': 'paused',
|
||||||
|
'fmt.through': 'In'
|
||||||
|
}
|
||||||
97
src/renderer/src/i18n/i18n.test.ts
Normal file
97
src/renderer/src/i18n/i18n.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { translate, translateN } from './index'
|
||||||
|
|
||||||
|
describe('translate', () => {
|
||||||
|
it('returns the matching string by key', () => {
|
||||||
|
expect(translate('ru', 'btn.save')).toBe('Сохранить')
|
||||||
|
expect(translate('en', 'btn.save')).toBe('Save')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the key when missing', () => {
|
||||||
|
expect(translate('ru', 'totally.unknown.key')).toBe('totally.unknown.key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('substitutes single variable', () => {
|
||||||
|
expect(translate('ru', 'btn.snooze_min', { n: 5 })).toBe('Отложить 5 мин')
|
||||||
|
expect(translate('en', 'btn.snooze_min', { n: 10 })).toBe('Snooze 10m')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('substitutes multiple variables', () => {
|
||||||
|
expect(
|
||||||
|
translate('en', 'updater.downloading.subtitle', {
|
||||||
|
got: '1.5',
|
||||||
|
total: '80.0',
|
||||||
|
speed: '2.5'
|
||||||
|
})
|
||||||
|
).toBe('1.5 / 80.0 MB · 2.5 MB/s')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles unknown language with fallback to ru', () => {
|
||||||
|
// @ts-expect-error testing fallback
|
||||||
|
expect(translate('fr', 'btn.save')).toBe('Сохранить')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('translateN (plural)', () => {
|
||||||
|
describe('russian plural rules', () => {
|
||||||
|
it('one: 1, 21, 101', () => {
|
||||||
|
expect(translateN('ru', 'match.summary.challenges', 1)).toBe('1 челлендж')
|
||||||
|
expect(translateN('ru', 'match.summary.challenges', 21)).toBe(
|
||||||
|
'21 челлендж'
|
||||||
|
)
|
||||||
|
expect(translateN('ru', 'match.summary.challenges', 101)).toBe(
|
||||||
|
'101 челлендж'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('few: 2, 3, 4, 22, 23, 24', () => {
|
||||||
|
expect(translateN('ru', 'match.summary.challenges', 2)).toBe(
|
||||||
|
'2 челленджа'
|
||||||
|
)
|
||||||
|
expect(translateN('ru', 'match.summary.challenges', 3)).toBe(
|
||||||
|
'3 челленджа'
|
||||||
|
)
|
||||||
|
expect(translateN('ru', 'match.summary.challenges', 22)).toBe(
|
||||||
|
'22 челленджа'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('many: 0, 5-20, 25-30, 111-114', () => {
|
||||||
|
expect(translateN('ru', 'match.summary.challenges', 0)).toBe(
|
||||||
|
'0 челленджей'
|
||||||
|
)
|
||||||
|
expect(translateN('ru', 'match.summary.challenges', 5)).toBe(
|
||||||
|
'5 челленджей'
|
||||||
|
)
|
||||||
|
expect(translateN('ru', 'match.summary.challenges', 11)).toBe(
|
||||||
|
'11 челленджей'
|
||||||
|
)
|
||||||
|
expect(translateN('ru', 'match.summary.challenges', 13)).toBe(
|
||||||
|
'13 челленджей'
|
||||||
|
)
|
||||||
|
expect(translateN('ru', 'match.summary.challenges', 20)).toBe(
|
||||||
|
'20 челленджей'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('english plural rules', () => {
|
||||||
|
it('one for 1', () => {
|
||||||
|
expect(translateN('en', 'match.summary.challenges', 1)).toBe(
|
||||||
|
'1 challenge'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('many/other for anything else', () => {
|
||||||
|
expect(translateN('en', 'match.summary.challenges', 0)).toBe(
|
||||||
|
'0 challenges'
|
||||||
|
)
|
||||||
|
expect(translateN('en', 'match.summary.challenges', 2)).toBe(
|
||||||
|
'2 challenges'
|
||||||
|
)
|
||||||
|
expect(translateN('en', 'match.summary.challenges', 21)).toBe(
|
||||||
|
'21 challenges'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
84
src/renderer/src/i18n/index.ts
Normal file
84
src/renderer/src/i18n/index.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useAppStore } from '../store/appStore'
|
||||||
|
import { ru, en, type Dict } from './dict'
|
||||||
|
import type { Language } from '@shared/types'
|
||||||
|
|
||||||
|
const dicts: Record<Language, Dict> = { ru, en }
|
||||||
|
|
||||||
|
export function getDict(lang: Language): Dict {
|
||||||
|
return dicts[lang] ?? ru
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TVars = Record<string, string | number>
|
||||||
|
export type TFn = (key: string, vars?: TVars) => string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a key in the dictionary, substitute `{var}` placeholders.
|
||||||
|
* Returns the key itself if not found — surfaces missing translations.
|
||||||
|
*/
|
||||||
|
export function translate(
|
||||||
|
lang: Language,
|
||||||
|
key: string,
|
||||||
|
vars?: TVars
|
||||||
|
): string {
|
||||||
|
const dict = getDict(lang)
|
||||||
|
let s = dict[key] ?? key
|
||||||
|
if (vars) {
|
||||||
|
for (const k of Object.keys(vars)) {
|
||||||
|
s = s.replace(new RegExp(`\\{${k}\\}`, 'g'), String(vars[k]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Russian CLDR plural categories — covers nominal forms.
|
||||||
|
* one → 1, 21, 31, 41… (но не 11)
|
||||||
|
* few → 2-4, 22-24… (но не 12-14)
|
||||||
|
* many → 0, 5-20, 25-30…
|
||||||
|
*/
|
||||||
|
function pluralRu(n: number): 'one' | 'few' | 'many' {
|
||||||
|
const mod10 = n % 10
|
||||||
|
const mod100 = n % 100
|
||||||
|
if (mod10 === 1 && mod100 !== 11) return 'one'
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 'few'
|
||||||
|
return 'many'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plural lookup. Pass `keyBase` like `match.summary.challenges` — the
|
||||||
|
* function appends `_one`/`_few`/`_many` (ru) or `_one`/`_many` (en).
|
||||||
|
* The `n` value is exposed as `{n}` in the resulting string.
|
||||||
|
*/
|
||||||
|
export function translateN(
|
||||||
|
lang: Language,
|
||||||
|
keyBase: string,
|
||||||
|
n: number,
|
||||||
|
vars?: TVars
|
||||||
|
): string {
|
||||||
|
const form =
|
||||||
|
lang === 'ru'
|
||||||
|
? pluralRu(n)
|
||||||
|
: n === 1
|
||||||
|
? 'one'
|
||||||
|
: 'many'
|
||||||
|
return translate(lang, `${keyBase}_${form}`, { n, ...vars })
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- React hook ---------------- */
|
||||||
|
|
||||||
|
export function useLang(): Language {
|
||||||
|
return useAppStore((s) => s.state?.settings?.language ?? 'ru')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useT(): {
|
||||||
|
t: (key: string, vars?: TVars) => string
|
||||||
|
tn: (keyBase: string, n: number, vars?: TVars) => string
|
||||||
|
lang: Language
|
||||||
|
} {
|
||||||
|
const lang = useLang()
|
||||||
|
return {
|
||||||
|
t: (key, vars) => translate(lang, key, vars),
|
||||||
|
tn: (keyBase, n, vars) => translateN(lang, keyBase, n, vars),
|
||||||
|
lang
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,29 @@
|
|||||||
export function formatCountdown(ms: number): string {
|
import type { Language } from '@shared/types'
|
||||||
if (ms <= 0) return 'сейчас'
|
|
||||||
|
const SUFFIX = {
|
||||||
|
ru: { now: 'сейчас', h: 'ч', m: 'м', s: 'с', minLong: 'мин', hLong: 'ч' },
|
||||||
|
en: { now: 'now', h: 'h', m: 'm', s: 's', minLong: 'min', hLong: 'h' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCountdown(ms: number, lang: Language = 'ru'): string {
|
||||||
|
const s = SUFFIX[lang] ?? SUFFIX.ru
|
||||||
|
if (ms <= 0) return s.now
|
||||||
const totalSec = Math.floor(ms / 1000)
|
const totalSec = Math.floor(ms / 1000)
|
||||||
const h = Math.floor(totalSec / 3600)
|
const h = Math.floor(totalSec / 3600)
|
||||||
const m = Math.floor((totalSec % 3600) / 60)
|
const m = Math.floor((totalSec % 3600) / 60)
|
||||||
const s = totalSec % 60
|
const sec = totalSec % 60
|
||||||
if (h > 0) return `${h}ч ${String(m).padStart(2, '0')}м`
|
if (h > 0) return `${h}${s.h} ${String(m).padStart(2, '0')}${s.m}`
|
||||||
if (m > 0) return `${m}м ${String(s).padStart(2, '0')}с`
|
if (m > 0) return `${m}${s.m} ${String(sec).padStart(2, '0')}${s.s}`
|
||||||
return `${s}с`
|
return `${sec}${s.s}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatInterval(minutes: number): string {
|
export function formatInterval(
|
||||||
if (minutes < 60) return `${minutes} мин`
|
minutes: number,
|
||||||
|
lang: Language = 'ru'
|
||||||
|
): string {
|
||||||
|
const s = SUFFIX[lang] ?? SUFFIX.ru
|
||||||
|
if (minutes < 60) return `${minutes} ${s.minLong}`
|
||||||
const h = Math.floor(minutes / 60)
|
const h = Math.floor(minutes / 60)
|
||||||
const m = minutes % 60
|
const m = minutes % 60
|
||||||
return m === 0 ? `${h} ч` : `${h} ч ${m} мин`
|
return m === 0 ? `${h} ${s.hLong}` : `${h} ${s.hLong} ${m} ${s.minLong}`
|
||||||
}
|
}
|
||||||
|
|||||||
127
src/renderer/src/lib/history.test.ts
Normal file
127
src/renderer/src/lib/history.test.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { Exercise, HistoryEntry } from '@shared/types'
|
||||||
|
import { currentStreak, dailyReps, dayKey, dailyRepsRange } from './history'
|
||||||
|
|
||||||
|
const MS_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function ex(id: string, reps: number): Exercise {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
reps,
|
||||||
|
icon: 'Activity',
|
||||||
|
intervalMinutes: 30,
|
||||||
|
enabled: true,
|
||||||
|
nextFireAt: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(
|
||||||
|
exerciseId: string,
|
||||||
|
ts: number,
|
||||||
|
action: 'done' | 'skip' | 'snooze' = 'done',
|
||||||
|
actualReps?: number
|
||||||
|
): HistoryEntry {
|
||||||
|
const e: HistoryEntry = { exerciseId, ts, action }
|
||||||
|
if (actualReps !== undefined) e.actualReps = actualReps
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dayKey', () => {
|
||||||
|
it('returns local YYYY-MM-DD', () => {
|
||||||
|
// Midnight local time is "today" — we cannot pin exact value across
|
||||||
|
// timezones, so just assert the format.
|
||||||
|
expect(dayKey(Date.now())).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dailyReps', () => {
|
||||||
|
const today = Date.now()
|
||||||
|
const exs = [ex('a', 10), ex('b', 5)]
|
||||||
|
|
||||||
|
it('counts planned reps when actualReps absent', () => {
|
||||||
|
const hist = [entry('a', today), entry('b', today)]
|
||||||
|
expect(dailyReps(hist, exs, dayKey(today))).toBe(15)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts actualReps when present (partial completion)', () => {
|
||||||
|
const hist = [entry('a', today, 'done', 7)]
|
||||||
|
expect(dailyReps(hist, exs, dayKey(today))).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores skip / snooze entries', () => {
|
||||||
|
const hist = [
|
||||||
|
entry('a', today, 'skip'),
|
||||||
|
entry('a', today, 'snooze'),
|
||||||
|
entry('b', today)
|
||||||
|
]
|
||||||
|
expect(dailyReps(hist, exs, dayKey(today))).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only counts the requested day', () => {
|
||||||
|
const yesterday = today - MS_DAY
|
||||||
|
const hist = [entry('a', today), entry('a', yesterday)]
|
||||||
|
expect(dailyReps(hist, exs, dayKey(today))).toBe(10)
|
||||||
|
expect(dailyReps(hist, exs, dayKey(yesterday))).toBe(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('currentStreak', () => {
|
||||||
|
const today = Date.now()
|
||||||
|
const day = (n: number): number => today - n * MS_DAY
|
||||||
|
|
||||||
|
it('returns 0 for empty history', () => {
|
||||||
|
expect(currentStreak([])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 0 if no done in last 2 days', () => {
|
||||||
|
expect(currentStreak([entry('a', day(3))])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts consecutive days ending today', () => {
|
||||||
|
const hist = [
|
||||||
|
entry('a', day(0)),
|
||||||
|
entry('a', day(1)),
|
||||||
|
entry('a', day(2)),
|
||||||
|
entry('a', day(4)) // gap
|
||||||
|
]
|
||||||
|
expect(currentStreak(hist)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows yesterday as grace day if today not done yet', () => {
|
||||||
|
const hist = [entry('a', day(1)), entry('a', day(2))]
|
||||||
|
expect(currentStreak(hist)).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores skip and snooze', () => {
|
||||||
|
const hist = [
|
||||||
|
entry('a', day(0), 'skip'),
|
||||||
|
entry('a', day(1), 'snooze')
|
||||||
|
]
|
||||||
|
expect(currentStreak(hist)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('multiple entries same day count once', () => {
|
||||||
|
const hist = [
|
||||||
|
entry('a', day(0)),
|
||||||
|
entry('b', day(0)),
|
||||||
|
entry('a', day(1))
|
||||||
|
]
|
||||||
|
expect(currentStreak(hist)).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dailyRepsRange', () => {
|
||||||
|
it('always returns exactly `days` entries even if no history', () => {
|
||||||
|
expect(dailyRepsRange([], [], 7)).toHaveLength(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sums reps into correct buckets', () => {
|
||||||
|
const today = Date.now()
|
||||||
|
const exs = [ex('a', 10)]
|
||||||
|
const hist = [entry('a', today), entry('a', today - MS_DAY, 'done', 3)]
|
||||||
|
const range = dailyRepsRange(hist, exs, 7)
|
||||||
|
expect(range.at(-1)?.reps).toBe(10) // today
|
||||||
|
expect(range.at(-2)?.reps).toBe(3) // yesterday, partial
|
||||||
|
})
|
||||||
|
})
|
||||||
119
src/renderer/src/lib/history.ts
Normal file
119
src/renderer/src/lib/history.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { Exercise, HistoryEntry } from '@shared/types'
|
||||||
|
|
||||||
|
const MS_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** YYYY-MM-DD in local time. */
|
||||||
|
export function dayKey(ts: number): string {
|
||||||
|
const d = new Date(ts)
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's local midnight. */
|
||||||
|
export function todayKey(): string {
|
||||||
|
return dayKey(Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reps logged on a given local day. Uses `actualReps` if present, otherwise
|
||||||
|
* looks up exercise's planned `reps`.
|
||||||
|
*/
|
||||||
|
export function dailyReps(
|
||||||
|
entries: HistoryEntry[],
|
||||||
|
exercises: Exercise[],
|
||||||
|
dayKeyStr: string
|
||||||
|
): number {
|
||||||
|
const byId = new Map(exercises.map((e) => [e.id, e]))
|
||||||
|
let sum = 0
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.action !== 'done') continue
|
||||||
|
if (dayKey(e.ts) !== dayKeyStr) continue
|
||||||
|
sum += e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of `dayKey → totalReps` for the last `days` days (most recent last).
|
||||||
|
* Missing days are still included with value 0.
|
||||||
|
*/
|
||||||
|
export function dailyRepsRange(
|
||||||
|
entries: HistoryEntry[],
|
||||||
|
exercises: Exercise[],
|
||||||
|
days: number
|
||||||
|
): { key: string; date: Date; reps: number }[] {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const buckets = new Map<string, number>()
|
||||||
|
const byId = new Map(exercises.map((e) => [e.id, e]))
|
||||||
|
|
||||||
|
// Seed all days with 0 so heatmap renders contiguous.
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(today.getTime() - i * MS_DAY)
|
||||||
|
buckets.set(dayKey(d.getTime()), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.action !== 'done') continue
|
||||||
|
const k = dayKey(e.ts)
|
||||||
|
if (!buckets.has(k)) continue
|
||||||
|
const reps = e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0
|
||||||
|
buckets.set(k, (buckets.get(k) ?? 0) + reps)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(buckets, ([key, reps]) => ({
|
||||||
|
key,
|
||||||
|
date: new Date(`${key}T00:00:00`),
|
||||||
|
reps
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current streak: consecutive days ending today (or yesterday — grace day)
|
||||||
|
* where at least one `done` was logged. Returns 0 if neither today nor
|
||||||
|
* yesterday has any done activity.
|
||||||
|
*/
|
||||||
|
export function currentStreak(entries: HistoryEntry[]): number {
|
||||||
|
const doneDays = new Set<string>()
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.action === 'done') doneDays.add(dayKey(e.ts))
|
||||||
|
}
|
||||||
|
if (doneDays.size === 0) return 0
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const todayK = dayKey(today.getTime())
|
||||||
|
const yesterdayK = dayKey(today.getTime() - MS_DAY)
|
||||||
|
|
||||||
|
// Start from today if active today, else yesterday (grace), else 0.
|
||||||
|
let cursor = doneDays.has(todayK)
|
||||||
|
? today
|
||||||
|
: doneDays.has(yesterdayK)
|
||||||
|
? new Date(today.getTime() - MS_DAY)
|
||||||
|
: null
|
||||||
|
if (!cursor) return 0
|
||||||
|
|
||||||
|
let streak = 0
|
||||||
|
while (doneDays.has(dayKey(cursor.getTime()))) {
|
||||||
|
streak++
|
||||||
|
cursor = new Date(cursor.getTime() - MS_DAY)
|
||||||
|
}
|
||||||
|
return streak
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total scheduled reps across all enabled exercises today (planned target). */
|
||||||
|
export function plannedRepsToday(exercises: Exercise[]): number {
|
||||||
|
// For now, "planned today" = sum of enabled exercises' reps × times per day
|
||||||
|
// approximation. A more honest target would count expected fires before
|
||||||
|
// midnight. We use a simple proxy: reps per exercise weighted by how often
|
||||||
|
// it'd fire in a day (1440 min / intervalMinutes).
|
||||||
|
let sum = 0
|
||||||
|
for (const e of exercises) {
|
||||||
|
if (!e.enabled) continue
|
||||||
|
const firesPerDay = Math.max(1, Math.floor(1440 / e.intervalMinutes))
|
||||||
|
sum += e.reps * firesPerDay
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
@@ -6,13 +6,15 @@ import { Switch } from '../components/ui/Switch'
|
|||||||
import { Modal } from '../components/ui/Modal'
|
import { Modal } from '../components/ui/Modal'
|
||||||
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
||||||
import { ICON_CHOICES, Icon } from '../lib/icon'
|
import { ICON_CHOICES, Icon } from '../lib/icon'
|
||||||
import { GAME_STATS, STAT_LABELS } from '@shared/types'
|
import { GAME_STATS, statLabel } from '@shared/types'
|
||||||
import type {
|
import type {
|
||||||
Challenge,
|
Challenge,
|
||||||
GameId,
|
GameId,
|
||||||
GameStat,
|
GameStat,
|
||||||
GameStatus
|
GameStatus,
|
||||||
|
Language
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
|
import { useT } from '../i18n'
|
||||||
|
|
||||||
const GAME_NAMES: Record<GameId, string> = {
|
const GAME_NAMES: Record<GameId, string> = {
|
||||||
dota2: 'Dota 2'
|
dota2: 'Dota 2'
|
||||||
@@ -35,6 +37,7 @@ export default function ChallengesPage(): JSX.Element {
|
|||||||
const [games, setGames] = useState<GameStatus[]>([])
|
const [games, setGames] = useState<GameStatus[]>([])
|
||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<Challenge | null>(null)
|
const [editing, setEditing] = useState<Challenge | null>(null)
|
||||||
|
const { t, lang } = useT()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void window.api.listGames().then(setGames)
|
void window.api.listGames().then(setGames)
|
||||||
@@ -49,13 +52,15 @@ export default function ChallengesPage(): JSX.Element {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[14px] text-text/65 font-semibold">
|
<div className="text-[14px] text-text/65 font-semibold">
|
||||||
Правила за матч
|
{t('challenges.kicker')}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
||||||
Челленджи
|
{t('challenges.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[15px] text-text/65 mt-2 font-medium">
|
<p className="text-[15px] text-text/65 mt-2 font-medium">
|
||||||
Повторов = <span className="font-mono-num font-semibold text-text">статистика × коэффициент</span>
|
{t('challenges.subtitle', {
|
||||||
|
formula: t('challenges.subtitle.formula')
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -64,7 +69,7 @@ export default function ChallengesPage(): JSX.Element {
|
|||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={15} strokeWidth={2.5} /> Новый
|
<Plus size={15} strokeWidth={2.5} /> {t('btn.new')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,15 +79,16 @@ export default function ChallengesPage(): JSX.Element {
|
|||||||
<AlertTriangle size={18} strokeWidth={2.5} />
|
<AlertTriangle size={18} strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[14px] text-text/85 leading-relaxed font-medium">
|
<div className="text-[14px] text-text/85 leading-relaxed font-medium">
|
||||||
Челленджи срабатывают после матча. Подключи игру во вкладке{' '}
|
{t('challenges.warning.no_games')}
|
||||||
<span className="font-semibold text-text">«Игры»</span>.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{challenges.length > 0 ? (
|
{challenges.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title={`Все · ${challenges.length}`} />
|
<SectionHeader
|
||||||
|
title={t('challenges.section.all', { n: challenges.length })}
|
||||||
|
/>
|
||||||
<Card>
|
<Card>
|
||||||
{challenges.map((c, i) => (
|
{challenges.map((c, i) => (
|
||||||
<Row
|
<Row
|
||||||
@@ -111,7 +117,7 @@ export default function ChallengesPage(): JSX.Element {
|
|||||||
<Gamepad2 size={12} strokeWidth={2.4} />
|
<Gamepad2 size={12} strokeWidth={2.4} />
|
||||||
{GAME_NAMES[c.gameId]} ·{' '}
|
{GAME_NAMES[c.gameId]} ·{' '}
|
||||||
<span className="font-mono-num font-semibold text-text">
|
<span className="font-mono-num font-semibold text-text">
|
||||||
{STAT_LABELS[c.stat]} × {c.multiplier}
|
{statLabel(c.stat, lang)} × {c.multiplier}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
→ {c.exerciseName}
|
→ {c.exerciseName}
|
||||||
</div>
|
</div>
|
||||||
@@ -129,8 +135,8 @@ export default function ChallengesPage(): JSX.Element {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="px-5 py-12 text-center text-text/55 text-[14px]">
|
<div className="px-5 py-12 text-center text-text/65 text-[15px] font-medium">
|
||||||
Челленджей пока нет. Привяжи упражнение к статистике матча.
|
{t('challenges.empty')}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -138,6 +144,7 @@ export default function ChallengesPage(): JSX.Element {
|
|||||||
<ChallengeEditor
|
<ChallengeEditor
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
challenge={editing}
|
challenge={editing}
|
||||||
|
lang={lang}
|
||||||
onClose={() => setEditorOpen(false)}
|
onClose={() => setEditorOpen(false)}
|
||||||
onSave={async (draft) => {
|
onSave={async (draft) => {
|
||||||
if (editing) await window.api.updateChallenge(editing.id, draft)
|
if (editing) await window.api.updateChallenge(editing.id, draft)
|
||||||
@@ -153,15 +160,18 @@ export default function ChallengesPage(): JSX.Element {
|
|||||||
function ChallengeEditor({
|
function ChallengeEditor({
|
||||||
open,
|
open,
|
||||||
challenge,
|
challenge,
|
||||||
|
lang,
|
||||||
onClose,
|
onClose,
|
||||||
onSave
|
onSave
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
challenge: Challenge | null
|
challenge: Challenge | null
|
||||||
|
lang: Language
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (draft: Draft) => void
|
onSave: (draft: Draft) => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT)
|
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT)
|
||||||
|
const { t } = useT()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (challenge) {
|
if (challenge) {
|
||||||
@@ -190,30 +200,34 @@ function ChallengeEditor({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={challenge ? 'Редактировать' : 'Новый челлендж'}
|
title={
|
||||||
|
challenge
|
||||||
|
? t('editor.challenge.title.edit')
|
||||||
|
: t('editor.challenge.title.new')
|
||||||
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="plain" onClick={onClose}>
|
<Button variant="plain" onClick={onClose}>
|
||||||
Отмена
|
{t('btn.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={!canSave} onClick={() => onSave(draft)}>
|
<Button disabled={!canSave} onClick={() => onSave(draft)}>
|
||||||
Сохранить
|
{t('btn.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Field label="Название">
|
<Field label={t('editor.field.challenge_name')}>
|
||||||
<input
|
<input
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
placeholder="За смерти — приседания"
|
placeholder={t('editor.field.challenge_name.placeholder')}
|
||||||
className="ios-input"
|
className="ios-input"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Игра">
|
<Field label={t('editor.field.game')}>
|
||||||
<select
|
<select
|
||||||
value={draft.gameId}
|
value={draft.gameId}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -230,7 +244,7 @@ function ChallengeEditor({
|
|||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Статистика">
|
<Field label={t('editor.field.stat')}>
|
||||||
<select
|
<select
|
||||||
value={draft.stat}
|
value={draft.stat}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -240,12 +254,12 @@ function ChallengeEditor({
|
|||||||
>
|
>
|
||||||
{GAME_STATS[draft.gameId].map((s) => (
|
{GAME_STATS[draft.gameId].map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s} value={s}>
|
||||||
{STAT_LABELS[s]}
|
{statLabel(s, lang)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Коэффициент">
|
<Field label={t('editor.field.multiplier')}>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
@@ -262,18 +276,18 @@ function ChallengeEditor({
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Упражнение">
|
<Field label={t('editor.field.exercise_name')}>
|
||||||
<input
|
<input
|
||||||
value={draft.exerciseName}
|
value={draft.exerciseName}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setDraft({ ...draft, exerciseName: e.target.value })
|
setDraft({ ...draft, exerciseName: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder="Приседания"
|
placeholder={t('editor.field.exercise_name.placeholder')}
|
||||||
className="ios-input"
|
className="ios-input"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Иконка">
|
<Field label={t('editor.field.icon')}>
|
||||||
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
|
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
|
||||||
{ICON_CHOICES.map((name) => (
|
{ICON_CHOICES.map((name) => (
|
||||||
<button
|
<button
|
||||||
@@ -293,13 +307,12 @@ function ChallengeEditor({
|
|||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{/* Live preview */}
|
|
||||||
<div className="rounded-2xl bg-accent/8 p-4">
|
<div className="rounded-2xl bg-accent/8 p-4">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-accent font-semibold mb-2">
|
<div className="text-[11px] uppercase tracking-wider text-accent font-semibold mb-2">
|
||||||
Превью · 5 событий
|
{t('editor.challenge.preview.kicker')}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono-num text-[14px] text-text/75 flex items-baseline gap-1.5 flex-wrap">
|
<div className="font-mono-num text-[14px] text-text/75 flex items-baseline gap-1.5 flex-wrap">
|
||||||
<span>5 {STAT_LABELS[draft.stat]}</span>
|
<span>5 {statLabel(draft.stat, lang)}</span>
|
||||||
<span className="text-text/40">×</span>
|
<span className="text-text/40">×</span>
|
||||||
<span>{draft.multiplier}</span>
|
<span>{draft.multiplier}</span>
|
||||||
<span className="text-text/40">=</span>
|
<span className="text-text/40">=</span>
|
||||||
@@ -307,12 +320,12 @@ function ChallengeEditor({
|
|||||||
{previewReps}
|
{previewReps}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-text/55">
|
<span className="text-text/55">
|
||||||
{draft.exerciseName.toLowerCase() || 'повторов'}
|
{draft.exerciseName.toLowerCase() ||
|
||||||
|
t('editor.challenge.preview.fallback')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.ios-input {
|
.ios-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,23 +1,39 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { Plus, Pause, Play, Flame, Activity } from 'lucide-react'
|
import { Plus, Pause, Play, Flame, Activity, TrendingUp } from 'lucide-react'
|
||||||
import { useAppStore } from '../store/appStore'
|
import { useAppStore } from '../store/appStore'
|
||||||
import { ExerciseCard } from '../components/ExerciseCard'
|
import { ExerciseCard } from '../components/ExerciseCard'
|
||||||
import { ExerciseEditor } from '../components/ExerciseEditor'
|
import { ExerciseEditor } from '../components/ExerciseEditor'
|
||||||
|
import { HistoryHeatmap } from '../components/HistoryHeatmap'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import type { Exercise } from '@shared/types'
|
import type { Exercise, HistoryEntry } from '@shared/types'
|
||||||
import { formatCountdown } from '../lib/format'
|
import { formatCountdown } from '../lib/format'
|
||||||
|
import { useT } from '../i18n'
|
||||||
|
import { currentStreak, dailyReps, todayKey } from '../lib/history'
|
||||||
|
|
||||||
export default function Dashboard(): JSX.Element {
|
export default function Dashboard(): JSX.Element {
|
||||||
const state = useAppStore((s) => s.state)
|
const state = useAppStore((s) => s.state)
|
||||||
const ticks = useAppStore((s) => s.ticks)
|
const ticks = useAppStore((s) => s.ticks)
|
||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<Exercise | null>(null)
|
const [editing, setEditing] = useState<Exercise | null>(null)
|
||||||
|
const { t, lang } = useT()
|
||||||
|
|
||||||
const exercises = state?.exercises ?? []
|
const exercises = state?.exercises ?? []
|
||||||
const settings = state?.settings
|
const settings = state?.settings
|
||||||
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
|
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
|
||||||
|
|
||||||
|
// Local history mirror; reloaded whenever app-state changes.
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
void window.api.getHistory().then(setHistory)
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
const todayDone = useMemo(
|
||||||
|
() => dailyReps(history, exercises, todayKey()),
|
||||||
|
[history, exercises]
|
||||||
|
)
|
||||||
|
const streak = useMemo(() => currentStreak(history), [history])
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const enabled = exercises.filter((e) => e.enabled)
|
const enabled = exercises.filter((e) => e.enabled)
|
||||||
const next = enabled
|
const next = enabled
|
||||||
@@ -57,70 +73,86 @@ export default function Dashboard(): JSX.Element {
|
|||||||
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
|
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toLocaleDateString('ru-RU', {
|
const today = new Date().toLocaleDateString(
|
||||||
weekday: 'long',
|
lang === 'en' ? 'en-US' : 'ru-RU',
|
||||||
day: 'numeric',
|
{ weekday: 'long', day: 'numeric', month: 'long' }
|
||||||
month: 'long'
|
)
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
||||||
{/* Hero — iOS Large Title */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[14px] text-text/65 font-semibold capitalize">
|
<div className="text-[14px] text-text/65 font-semibold capitalize">
|
||||||
{today}
|
{today}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
||||||
Сегодня
|
{t('dashboard.title')}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="tinted" onClick={togglePause}>
|
<Button variant="tinted" onClick={togglePause}>
|
||||||
{!paused ? (
|
{!paused ? (
|
||||||
<>
|
<>
|
||||||
<Pause size={14} strokeWidth={2.5} /> Пауза
|
<Pause size={14} strokeWidth={2.5} /> {t('btn.pause')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play size={14} strokeWidth={2.5} /> Старт
|
<Play size={14} strokeWidth={2.5} /> {t('btn.start')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate}>
|
||||||
<Plus size={15} strokeWidth={2.5} /> Добавить
|
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero stat panel — Apple Fitness style */}
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8">
|
|
||||||
<HeroStat
|
<HeroStat
|
||||||
tone="accent"
|
tone="accent"
|
||||||
label="Активных"
|
label={t('dashboard.stat.today_done')}
|
||||||
value={`${stats.active}`}
|
value={`${todayDone}`}
|
||||||
subvalue={`из ${stats.total}`}
|
subvalue={t('dashboard.stat.today_done.subtitle')}
|
||||||
icon={<Activity size={14} strokeWidth={2.6} />}
|
icon={<TrendingUp size={14} strokeWidth={2.6} />}
|
||||||
|
/>
|
||||||
|
<HeroStat
|
||||||
|
tone={streak > 0 ? 'warning' : 'muted'}
|
||||||
|
label={t('dashboard.stat.streak')}
|
||||||
|
value={`${streak}`}
|
||||||
|
subvalue={t('dashboard.stat.streak.subtitle', { n: streak })}
|
||||||
|
icon={<Flame size={14} strokeWidth={2.6} />}
|
||||||
/>
|
/>
|
||||||
<HeroStat
|
<HeroStat
|
||||||
tone="info"
|
tone="info"
|
||||||
label="До следующего"
|
label={t('dashboard.stat.next')}
|
||||||
value={
|
value={
|
||||||
stats.nextMs === Infinity
|
stats.nextMs === Infinity
|
||||||
? '—'
|
? '—'
|
||||||
: stats.nextMs <= 0
|
: stats.nextMs <= 0
|
||||||
? 'Сейчас'
|
? t('dashboard.stat.next.now')
|
||||||
: formatCountdown(stats.nextMs)
|
: formatCountdown(stats.nextMs, lang)
|
||||||
}
|
}
|
||||||
subvalue={paused ? 'на паузе' : 'отсчёт идёт'}
|
subvalue={
|
||||||
icon={<Flame size={14} strokeWidth={2.6} />}
|
paused
|
||||||
|
? t('dashboard.stat.next.subtitle_paused')
|
||||||
|
: t('dashboard.stat.next.subtitle_running')
|
||||||
|
}
|
||||||
|
icon={<Activity size={14} strokeWidth={2.6} />}
|
||||||
/>
|
/>
|
||||||
<HeroStat
|
<HeroStat
|
||||||
tone={gamesEnabled ? 'success' : 'muted'}
|
tone={gamesEnabled ? 'success' : 'muted'}
|
||||||
label="Трекинг матчей"
|
label={t('dashboard.stat.tracking')}
|
||||||
value={gamesEnabled ? 'On' : 'Off'}
|
value={
|
||||||
subvalue={gamesEnabled ? 'в реальном времени' : 'выключен'}
|
gamesEnabled
|
||||||
|
? t('dashboard.stat.tracking.on')
|
||||||
|
: t('dashboard.stat.tracking.off')
|
||||||
|
}
|
||||||
|
subvalue={
|
||||||
|
gamesEnabled
|
||||||
|
? t('dashboard.stat.tracking.subtitle_on')
|
||||||
|
: t('dashboard.stat.tracking.subtitle_off')
|
||||||
|
}
|
||||||
icon={
|
icon={
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
@@ -132,7 +164,16 @@ export default function Dashboard(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Paused banner */}
|
{history.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<HistoryHeatmap
|
||||||
|
history={history}
|
||||||
|
exercises={exercises}
|
||||||
|
lang={lang}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{paused && (
|
{paused && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -4 }}
|
initial={{ opacity: 0, y: -4 }}
|
||||||
@@ -144,19 +185,18 @@ export default function Dashboard(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[16px] font-semibold leading-tight">
|
<div className="text-[16px] font-semibold leading-tight">
|
||||||
Напоминания на паузе
|
{t('dashboard.paused.title')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[14px] text-text/70 mt-1">
|
<div className="text-[14px] text-text/70 mt-1">
|
||||||
Возобнови, чтобы продолжить отсчёт
|
{t('dashboard.paused.hint')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="filled" size="sm" onClick={togglePause}>
|
<Button variant="filled" size="sm" onClick={togglePause}>
|
||||||
<Play size={14} strokeWidth={2.5} /> Старт
|
<Play size={14} strokeWidth={2.5} /> {t('btn.start')}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cards grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{exercises.map((ex) => (
|
{exercises.map((ex) => (
|
||||||
@@ -179,10 +219,10 @@ export default function Dashboard(): JSX.Element {
|
|||||||
<Plus size={24} strokeWidth={2.5} />
|
<Plus size={24} strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<div className="font-display text-[20px] font-semibold">
|
<div className="font-display text-[20px] font-semibold">
|
||||||
Программа пуста
|
{t('dashboard.empty.title')}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[14px] text-text/55 mt-1">
|
<p className="text-[14px] text-text/55 mt-1">
|
||||||
Добавь первое упражнение, чтобы начать
|
{t('dashboard.empty.hint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -205,7 +245,7 @@ function HeroStat({
|
|||||||
subvalue,
|
subvalue,
|
||||||
icon
|
icon
|
||||||
}: {
|
}: {
|
||||||
tone: 'accent' | 'info' | 'success' | 'muted'
|
tone: 'accent' | 'info' | 'success' | 'warning' | 'muted'
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
subvalue?: string
|
subvalue?: string
|
||||||
@@ -218,6 +258,8 @@ function HeroStat({
|
|||||||
? 'bg-info'
|
? 'bg-info'
|
||||||
: tone === 'success'
|
: tone === 'success'
|
||||||
? 'bg-success'
|
? 'bg-success'
|
||||||
|
: tone === 'warning'
|
||||||
|
? 'bg-warning'
|
||||||
: 'bg-text/40'
|
: 'bg-text/40'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { Switch } from '../components/ui/Switch'
|
|||||||
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
||||||
import { Icon } from '../lib/icon'
|
import { Icon } from '../lib/icon'
|
||||||
import { formatInterval } from '../lib/format'
|
import { formatInterval } from '../lib/format'
|
||||||
|
import { useT } from '../i18n'
|
||||||
import type { Exercise } from '@shared/types'
|
import type { Exercise } from '@shared/types'
|
||||||
|
|
||||||
export default function Exercises(): JSX.Element {
|
export default function Exercises(): JSX.Element {
|
||||||
const exercises = useAppStore((s) => s.state?.exercises ?? [])
|
const exercises = useAppStore((s) => s.state?.exercises ?? [])
|
||||||
const [editorOpen, setEditorOpen] = useState(false)
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<Exercise | null>(null)
|
const [editing, setEditing] = useState<Exercise | null>(null)
|
||||||
|
const { t, lang } = useT()
|
||||||
|
|
||||||
const enabled = exercises.filter((e) => e.enabled)
|
const enabled = exercises.filter((e) => e.enabled)
|
||||||
const disabled = exercises.filter((e) => !e.enabled)
|
const disabled = exercises.filter((e) => !e.enabled)
|
||||||
@@ -23,10 +25,10 @@ export default function Exercises(): JSX.Element {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[14px] text-text/65 font-semibold">
|
<div className="text-[14px] text-text/65 font-semibold">
|
||||||
Программа
|
{t('exercises.kicker')}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
||||||
Упражнения
|
{t('exercises.title')}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -35,19 +37,25 @@ export default function Exercises(): JSX.Element {
|
|||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={15} strokeWidth={2.5} /> Добавить
|
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{enabled.length > 0 && (
|
{enabled.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title={`Активные · ${enabled.length}`} />
|
<SectionHeader
|
||||||
|
title={t('exercises.section.active', { n: enabled.length })}
|
||||||
|
/>
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
{enabled.map((ex, i) => (
|
{enabled.map((ex, i) => (
|
||||||
<ExerciseRow
|
<ExerciseRow
|
||||||
key={ex.id}
|
key={ex.id}
|
||||||
exercise={ex}
|
exercise={ex}
|
||||||
last={i === enabled.length - 1}
|
last={i === enabled.length - 1}
|
||||||
|
meta={t('exercises.row.meta', {
|
||||||
|
reps: ex.reps,
|
||||||
|
interval: formatInterval(ex.intervalMinutes, lang)
|
||||||
|
})}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditing(ex)
|
setEditing(ex)
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
@@ -60,13 +68,19 @@ export default function Exercises(): JSX.Element {
|
|||||||
|
|
||||||
{disabled.length > 0 && (
|
{disabled.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title={`Выключенные · ${disabled.length}`} />
|
<SectionHeader
|
||||||
|
title={t('exercises.section.disabled', { n: disabled.length })}
|
||||||
|
/>
|
||||||
<Card>
|
<Card>
|
||||||
{disabled.map((ex, i) => (
|
{disabled.map((ex, i) => (
|
||||||
<ExerciseRow
|
<ExerciseRow
|
||||||
key={ex.id}
|
key={ex.id}
|
||||||
exercise={ex}
|
exercise={ex}
|
||||||
last={i === disabled.length - 1}
|
last={i === disabled.length - 1}
|
||||||
|
meta={t('exercises.row.meta', {
|
||||||
|
reps: ex.reps,
|
||||||
|
interval: formatInterval(ex.intervalMinutes, lang)
|
||||||
|
})}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditing(ex)
|
setEditing(ex)
|
||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
@@ -80,7 +94,7 @@ 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 text-center text-text/65 text-[15px] font-medium">
|
||||||
Программа пуста — добавь первое упражнение
|
{t('exercises.empty')}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -103,15 +117,16 @@ export default function Exercises(): JSX.Element {
|
|||||||
function ExerciseRow({
|
function ExerciseRow({
|
||||||
exercise,
|
exercise,
|
||||||
last,
|
last,
|
||||||
|
meta,
|
||||||
onEdit
|
onEdit
|
||||||
}: {
|
}: {
|
||||||
exercise: Exercise
|
exercise: Exercise
|
||||||
last: boolean
|
last: boolean
|
||||||
|
meta: string
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Row last={last}>
|
<Row last={last}>
|
||||||
{/* Tinted icon plaque, iOS Settings style */}
|
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'w-9 h-9 rounded-lg grid place-items-center shrink-0',
|
'w-9 h-9 rounded-lg grid place-items-center shrink-0',
|
||||||
@@ -129,19 +144,15 @@ function ExerciseRow({
|
|||||||
<div className="text-[16px] font-semibold truncate leading-tight">
|
<div className="text-[16px] font-semibold truncate leading-tight">
|
||||||
{exercise.name}
|
{exercise.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[14px] text-text/65 mt-1 font-medium">
|
<div className="text-[14px] text-text/65 mt-1 font-medium">{meta}</div>
|
||||||
{exercise.reps} раз · {formatInterval(exercise.intervalMinutes)}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
<Switch
|
<Switch
|
||||||
checked={exercise.enabled}
|
checked={exercise.enabled}
|
||||||
onChange={(v) => window.api.toggleExercise(exercise.id, v)}
|
onChange={(v) => window.api.toggleExercise(exercise.id, v)}
|
||||||
aria-label="Включить/выключить"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
className="text-text/30 hover:text-text/60 transition-colors"
|
className="text-text/30 hover:text-text/60 transition-colors"
|
||||||
aria-label="Редактировать"
|
|
||||||
>
|
>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import { Button } from '../components/ui/Button'
|
|||||||
import { Switch } from '../components/ui/Switch'
|
import { Switch } from '../components/ui/Switch'
|
||||||
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'
|
||||||
|
|
||||||
export default function GamesPage(): JSX.Element {
|
export default function GamesPage(): JSX.Element {
|
||||||
const [games, setGames] = useState<GameStatus[]>([])
|
const [games, setGames] = useState<GameStatus[]>([])
|
||||||
const [busy, setBusy] = useState<GameId | null>(null)
|
const [busy, setBusy] = useState<GameId | null>(null)
|
||||||
|
const { t } = useT()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh()
|
void refresh()
|
||||||
@@ -62,29 +64,29 @@ export default function GamesPage(): JSX.Element {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[14px] text-text/65 font-semibold">
|
<div className="text-[14px] text-text/65 font-semibold">
|
||||||
Трекинг матчей
|
{t('games.kicker')}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
||||||
Игры
|
{t('games.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[15px] text-text/65 mt-2 font-medium leading-relaxed">
|
<p className="text-[15px] text-text/65 mt-2 font-medium leading-relaxed">
|
||||||
Подключи игру — челленджи сработают сразу после матча
|
{t('games.subtitle')}
|
||||||
{liveCount > 0 && (
|
{liveCount > 0 && (
|
||||||
<>
|
<>
|
||||||
{' · '}
|
{' · '}
|
||||||
<span className="text-success font-mono-num font-bold">
|
<span className="text-success font-mono-num font-bold">
|
||||||
{liveCount} live
|
{t('games.subtitle.live', { n: liveCount })}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="tinted" onClick={refresh}>
|
<Button variant="tinted" onClick={refresh}>
|
||||||
<RefreshCw size={14} strokeWidth={2.5} /> Обновить
|
<RefreshCw size={14} strokeWidth={2.5} /> {t('btn.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SectionHeader title="Поддерживаемые" />
|
<SectionHeader title={t('games.section.supported')} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{games.map((g, i) => (
|
{games.map((g, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -105,7 +107,7 @@ 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 text-center text-text/55 text-[14px]">
|
||||||
Сканируем установленные игры…
|
{t('games.scanning')}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -130,6 +132,7 @@ function GameCard({
|
|||||||
onUninstall: () => void
|
onUninstall: () => void
|
||||||
onToggle: (v: boolean) => void
|
onToggle: (v: boolean) => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
|
const { t } = useT()
|
||||||
const isLive =
|
const isLive =
|
||||||
game.installed &&
|
game.installed &&
|
||||||
game.integrationActive &&
|
game.integrationActive &&
|
||||||
@@ -183,11 +186,9 @@ function GameCard({
|
|||||||
strokeWidth={2.4}
|
strokeWidth={2.4}
|
||||||
/>
|
/>
|
||||||
<div className="text-text/85">
|
<div className="text-text/85">
|
||||||
Steam запущен. Параметр{' '}
|
{t('games.queued.body', {
|
||||||
<code className="px-1.5 py-0.5 rounded-md bg-surface text-accent font-mono-num text-[13px] font-semibold">
|
opt: game.launchOption ?? '-gamestateintegration'
|
||||||
{game.launchOption}
|
})}
|
||||||
</code>{' '}
|
|
||||||
пропишется автоматически при следующем закрытии Steam.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -199,18 +200,14 @@ function GameCard({
|
|||||||
className="text-destructive shrink-0 mt-0.5"
|
className="text-destructive shrink-0 mt-0.5"
|
||||||
strokeWidth={2.4}
|
strokeWidth={2.4}
|
||||||
/>
|
/>
|
||||||
<div className="text-text/85">
|
<div className="text-text/85">{t('games.no_user.body')}</div>
|
||||||
В Steam нет залогиненного аккаунта (нет папки{' '}
|
|
||||||
<code className="font-mono-num text-[13px] font-semibold">userdata</code>).
|
|
||||||
Запусти Steam один раз и нажми «Установить интеграцию».
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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} /> Подключить
|
<Download size={14} strokeWidth={2.5} /> {t('btn.connect')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{game.integrationActive && (
|
{game.integrationActive && (
|
||||||
@@ -220,12 +217,12 @@ function GameCard({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} strokeWidth={2.5} /> Отключить
|
<Trash2 size={14} strokeWidth={2.5} /> {t('btn.disconnect')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!game.installed && (
|
{!game.installed && (
|
||||||
<div className="text-[14px] text-text/65 font-medium">
|
<div className="text-[14px] text-text/65 font-medium">
|
||||||
Установи игру в Steam и нажми «Обновить»
|
{t('games.not_installed.hint')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -240,6 +237,7 @@ function StatusBadge({
|
|||||||
game: GameStatus
|
game: GameStatus
|
||||||
isLive: boolean
|
isLive: boolean
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
|
const { t } = useT()
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
return (
|
return (
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full bg-success/15 text-success font-semibold inline-flex items-center gap-1.5">
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-success/15 text-success font-semibold inline-flex items-center gap-1.5">
|
||||||
@@ -247,40 +245,41 @@ function StatusBadge({
|
|||||||
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
|
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
|
||||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
|
||||||
</span>
|
</span>
|
||||||
Live
|
{t('games.badge.live')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (game.integrationActive && game.launchOptionStatus === 'applied') {
|
if (game.integrationActive && game.launchOptionStatus === 'applied') {
|
||||||
return (
|
return (
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full bg-accent/15 text-accent font-semibold inline-flex items-center gap-1.5">
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-accent/15 text-accent font-semibold inline-flex items-center gap-1.5">
|
||||||
<CheckCircle2 size={11} strokeWidth={2.5} /> Готово
|
<CheckCircle2 size={11} strokeWidth={2.5} /> {t('games.badge.ready')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (game.integrationActive && game.launchOptionStatus === 'queued') {
|
if (game.integrationActive && game.launchOptionStatus === 'queued') {
|
||||||
return (
|
return (
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full bg-warning/15 text-warning font-semibold">
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-warning/15 text-warning font-semibold">
|
||||||
В очереди
|
{t('games.badge.queued')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (game.installed) {
|
if (game.installed) {
|
||||||
return (
|
return (
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/70 font-semibold">
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/70 font-semibold">
|
||||||
Установлена
|
{t('games.badge.installed')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/45 font-semibold">
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-text/10 text-text/45 font-semibold">
|
||||||
Не найдена
|
{t('games.badge.not_found')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
|
function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const { t } = useT()
|
||||||
const dota = games.find((g) => g.id === 'dota2')
|
const dota = games.find((g) => g.id === 'dota2')
|
||||||
if (!dota?.enabled) return null
|
if (!dota?.enabled) return null
|
||||||
return (
|
return (
|
||||||
@@ -289,15 +288,15 @@ function DevPanel({ games }: { games: GameStatus[] }): JSX.Element | null {
|
|||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className="text-[12px] uppercase tracking-wider text-text/40 hover:text-text/70 font-mono-num font-medium transition-colors"
|
className="text-[12px] uppercase tracking-wider text-text/40 hover:text-text/70 font-mono-num font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{open ? '▾' : '▸'} dev · симулировать конец матча
|
{open ? '▾' : '▸'} {t('games.dev.toggle')}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
{ label: '5 смертей', stats: { deaths: 5 } },
|
{ label: '5 deaths', stats: { deaths: 5 } },
|
||||||
{ label: '10 смертей', stats: { deaths: 10 } },
|
{ label: '10 deaths', stats: { deaths: 10 } },
|
||||||
{ label: '15 убийств', stats: { kills: 15 } },
|
{ label: '15 kills', stats: { kills: 15 } },
|
||||||
{
|
{
|
||||||
label: 'KDA 8/3/12',
|
label: 'KDA 8/3/12',
|
||||||
stats: { kills: 8, deaths: 3, assists: 12 }
|
stats: { kills: 8, deaths: 3, assists: 12 }
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ import { useAppStore } from '../store/appStore'
|
|||||||
import { Switch } from '../components/ui/Switch'
|
import { Switch } from '../components/ui/Switch'
|
||||||
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
import { Card, Row, SectionHeader } from '../components/ui/Card'
|
||||||
import { UpdaterCard } from '../components/UpdaterCard'
|
import { UpdaterCard } from '../components/UpdaterCard'
|
||||||
|
import { useT } from '../i18n'
|
||||||
import type {
|
import type {
|
||||||
|
Language,
|
||||||
NotificationMode,
|
NotificationMode,
|
||||||
|
QuietHours,
|
||||||
Settings as SettingsType,
|
Settings as SettingsType,
|
||||||
Theme
|
Theme
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
|
|
||||||
export default function SettingsPage(): JSX.Element {
|
export default function SettingsPage(): JSX.Element {
|
||||||
const settings = useAppStore((s) => s.state?.settings)
|
const settings = useAppStore((s) => s.state?.settings)
|
||||||
|
const { t } = useT()
|
||||||
if (!settings)
|
if (!settings)
|
||||||
return <div className="p-8 text-text/45">Загрузка…</div>
|
return <div className="p-8 text-text/45">{t('settings.loading')}</div>
|
||||||
|
|
||||||
const patch = (p: Partial<SettingsType>): void => {
|
const patch = (p: Partial<SettingsType>): void => {
|
||||||
window.api.updateSettings(p)
|
window.api.updateSettings(p)
|
||||||
@@ -22,67 +26,112 @@ export default function SettingsPage(): JSX.Element {
|
|||||||
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="text-[14px] text-text/65 font-semibold">
|
<div className="text-[14px] text-text/65 font-semibold">
|
||||||
Конфигурация
|
{t('settings.kicker')}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
||||||
Настройки
|
{t('settings.title')}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reminders */}
|
<SectionHeader title={t('settings.section.language')} />
|
||||||
<SectionHeader title="Напоминания" />
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<SelectRow
|
<SelectRow
|
||||||
label="Режим уведомления"
|
label={t('settings.language.label')}
|
||||||
hint="Как должно выглядеть напоминание"
|
hint={t('settings.language.hint')}
|
||||||
value={settings.notificationMode}
|
value={settings.language}
|
||||||
onChange={(v) => patch({ notificationMode: v as NotificationMode })}
|
onChange={(v) => patch({ language: v as Language })}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'modal', label: 'Окно поверх всех' },
|
{ value: 'ru', label: t('settings.language.ru') },
|
||||||
{ value: 'toast', label: 'Системное уведомление' },
|
{ value: 'en', label: t('settings.language.en') }
|
||||||
{ value: 'both', label: 'Окно и уведомление' }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<ToggleRow
|
|
||||||
label="Звук уведомления"
|
|
||||||
hint="Короткий сигнал при срабатывании"
|
|
||||||
checked={settings.soundEnabled}
|
|
||||||
onChange={(v) => patch({ soundEnabled: v })}
|
|
||||||
/>
|
|
||||||
<SelectRow
|
|
||||||
label="«Отложить» на"
|
|
||||||
hint="Сколько минут добавлять при отложении"
|
|
||||||
value={String(settings.snoozeMinutes)}
|
|
||||||
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
|
|
||||||
options={[
|
|
||||||
{ value: '1', label: '1 минута' },
|
|
||||||
{ value: '5', label: '5 минут' },
|
|
||||||
{ value: '10', label: '10 минут' },
|
|
||||||
{ value: '15', label: '15 минут' },
|
|
||||||
{ value: '30', label: '30 минут' }
|
|
||||||
]}
|
]}
|
||||||
last
|
last
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Window */}
|
<SectionHeader title={t('settings.section.reminders')} />
|
||||||
<SectionHeader title="Окно и трей" />
|
<Card className="mb-6">
|
||||||
|
<SelectRow
|
||||||
|
label={t('settings.notification_mode.label')}
|
||||||
|
hint={t('settings.notification_mode.hint')}
|
||||||
|
value={settings.notificationMode}
|
||||||
|
onChange={(v) => patch({ notificationMode: v as NotificationMode })}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'modal',
|
||||||
|
label: t('settings.notification_mode.modal')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'toast',
|
||||||
|
label: t('settings.notification_mode.toast')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'both',
|
||||||
|
label: t('settings.notification_mode.both')
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label={t('settings.sound.label')}
|
||||||
|
hint={t('settings.sound.hint')}
|
||||||
|
checked={settings.soundEnabled}
|
||||||
|
onChange={(v) => patch({ soundEnabled: v })}
|
||||||
|
/>
|
||||||
|
<SelectRow
|
||||||
|
label={t('settings.snooze.label')}
|
||||||
|
hint={t('settings.snooze.hint')}
|
||||||
|
value={String(settings.snoozeMinutes)}
|
||||||
|
onChange={(v) => patch({ snoozeMinutes: Number(v) })}
|
||||||
|
options={[
|
||||||
|
{ value: '1', label: t('settings.snooze.1') },
|
||||||
|
{ value: '5', label: t('settings.snooze.5') },
|
||||||
|
{ value: '10', label: t('settings.snooze.10') },
|
||||||
|
{ value: '15', label: t('settings.snooze.15') },
|
||||||
|
{ value: '30', label: t('settings.snooze.30') }
|
||||||
|
]}
|
||||||
|
last
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<SectionHeader title={t('settings.section.quiet')} />
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Сворачивать в трей"
|
label={t('settings.quiet.enabled.label')}
|
||||||
hint="При закрытии остаётся работать в фоне"
|
hint={t('settings.quiet.enabled.hint')}
|
||||||
|
checked={settings.quietHours.enabled}
|
||||||
|
onChange={(v) =>
|
||||||
|
patch({ quietHours: { ...settings.quietHours, enabled: v } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<QuietTimesRow
|
||||||
|
qh={settings.quietHours}
|
||||||
|
onChange={(qh) => patch({ quietHours: qh })}
|
||||||
|
disabled={!settings.quietHours.enabled}
|
||||||
|
/>
|
||||||
|
<QuietDaysRow
|
||||||
|
qh={settings.quietHours}
|
||||||
|
onChange={(qh) => patch({ quietHours: qh })}
|
||||||
|
disabled={!settings.quietHours.enabled}
|
||||||
|
last
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<SectionHeader title={t('settings.section.window')} />
|
||||||
|
<Card className="mb-6">
|
||||||
|
<ToggleRow
|
||||||
|
label={t('settings.tray.label')}
|
||||||
|
hint={t('settings.tray.hint')}
|
||||||
checked={settings.minimizeToTray}
|
checked={settings.minimizeToTray}
|
||||||
onChange={(v) => patch({ minimizeToTray: v })}
|
onChange={(v) => patch({ minimizeToTray: v })}
|
||||||
/>
|
/>
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Запускать с Windows"
|
label={t('settings.autostart.label')}
|
||||||
hint="Открывать при входе в систему"
|
hint={t('settings.autostart.hint')}
|
||||||
checked={settings.startWithWindows}
|
checked={settings.startWithWindows}
|
||||||
onChange={(v) => patch({ startWithWindows: v })}
|
onChange={(v) => patch({ startWithWindows: v })}
|
||||||
/>
|
/>
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Запускать свёрнутым"
|
label={t('settings.start_minimized.label')}
|
||||||
hint="При автозапуске открывать сразу в трее"
|
hint={t('settings.start_minimized.hint')}
|
||||||
checked={settings.startMinimized}
|
checked={settings.startMinimized}
|
||||||
onChange={(v) => patch({ startMinimized: v })}
|
onChange={(v) => patch({ startMinimized: v })}
|
||||||
disabled={!settings.startWithWindows}
|
disabled={!settings.startWithWindows}
|
||||||
@@ -90,24 +139,23 @@ export default function SettingsPage(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Appearance */}
|
<SectionHeader title={t('settings.section.appearance')} />
|
||||||
<SectionHeader title="Внешний вид" />
|
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<SelectRow
|
<SelectRow
|
||||||
label="Тема"
|
label={t('settings.theme.label')}
|
||||||
hint="Светлая / тёмная / как в системе"
|
hint={t('settings.theme.hint')}
|
||||||
value={settings.theme}
|
value={settings.theme}
|
||||||
onChange={(v) => patch({ theme: v as Theme })}
|
onChange={(v) => patch({ theme: v as Theme })}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'system', label: 'Как в системе' },
|
{ value: 'system', label: t('settings.theme.system') },
|
||||||
{ value: 'light', label: 'Светлая' },
|
{ value: 'light', label: t('settings.theme.light') },
|
||||||
{ value: 'dark', label: 'Тёмная' }
|
{ value: 'dark', label: t('settings.theme.dark') }
|
||||||
]}
|
]}
|
||||||
last
|
last
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<SectionHeader title="Обновления" />
|
<SectionHeader title={t('settings.section.updates')} />
|
||||||
<UpdaterCard />
|
<UpdaterCard />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,6 +192,108 @@ function ToggleRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QuietTimesRow({
|
||||||
|
qh,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
last = false
|
||||||
|
}: {
|
||||||
|
qh: QuietHours
|
||||||
|
onChange: (next: QuietHours) => void
|
||||||
|
disabled?: boolean
|
||||||
|
last?: boolean
|
||||||
|
}): JSX.Element {
|
||||||
|
const { t } = useT()
|
||||||
|
return (
|
||||||
|
<Row last={last} className={disabled ? 'opacity-50' : ''}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[15px] font-semibold leading-tight">
|
||||||
|
{t('settings.quiet.times.label')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||||
|
{t('settings.quiet.times.hint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={qh.from}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...qh, from: e.target.value })}
|
||||||
|
className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num"
|
||||||
|
/>
|
||||||
|
<span className="text-text/45 text-[14px]">—</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={qh.to}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...qh, to: e.target.value })}
|
||||||
|
className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuietDaysRow({
|
||||||
|
qh,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
last = false
|
||||||
|
}: {
|
||||||
|
qh: QuietHours
|
||||||
|
onChange: (next: QuietHours) => void
|
||||||
|
disabled?: boolean
|
||||||
|
last?: boolean
|
||||||
|
}): JSX.Element {
|
||||||
|
const { t, lang } = useT()
|
||||||
|
const labels =
|
||||||
|
lang === 'en'
|
||||||
|
? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
|
||||||
|
|
||||||
|
function toggle(d: number): void {
|
||||||
|
const set = new Set(qh.days)
|
||||||
|
if (set.has(d)) set.delete(d)
|
||||||
|
else set.add(d)
|
||||||
|
onChange({ ...qh, days: Array.from(set).sort() })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row last={last} className={disabled ? 'opacity-50' : ''}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[15px] font-semibold leading-tight">
|
||||||
|
{t('settings.quiet.days.label')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||||
|
{t('settings.quiet.days.hint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap justify-end max-w-[60%]">
|
||||||
|
{labels.map((label, d) => {
|
||||||
|
const on = qh.days.includes(d)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => toggle(d)}
|
||||||
|
className={[
|
||||||
|
'h-7 min-w-[28px] px-1.5 rounded-full text-[11px] font-semibold transition-all',
|
||||||
|
on
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'bg-surface-2 text-text/55 hover:text-text'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SelectRow({
|
function SelectRow({
|
||||||
label,
|
label,
|
||||||
hint,
|
hint,
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export const IPC = {
|
|||||||
updaterDownload: 'updater:download',
|
updaterDownload: 'updater:download',
|
||||||
updaterInstall: 'updater:install',
|
updaterInstall: 'updater:install',
|
||||||
|
|
||||||
|
// History
|
||||||
|
getHistory: 'history:get',
|
||||||
|
clearHistory: 'history:clear',
|
||||||
|
|
||||||
// events from main → renderer
|
// events from main → renderer
|
||||||
evtTick: 'evt:tick',
|
evtTick: 'evt:tick',
|
||||||
evtFire: 'evt:fire',
|
evtFire: 'evt:fire',
|
||||||
|
|||||||
71
src/shared/quiet-hours.test.ts
Normal file
71
src/shared/quiet-hours.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isQuietAt, type QuietHours } from './types'
|
||||||
|
|
||||||
|
function at(iso: string): Date {
|
||||||
|
return new Date(iso)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6]
|
||||||
|
|
||||||
|
describe('isQuietAt', () => {
|
||||||
|
it('returns false when disabled', () => {
|
||||||
|
const qh: QuietHours = {
|
||||||
|
enabled: false,
|
||||||
|
from: '00:00',
|
||||||
|
to: '23:59',
|
||||||
|
days: ALL_DAYS
|
||||||
|
}
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T12:00:00'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('same-day window: inside is quiet, outside is not', () => {
|
||||||
|
const qh: QuietHours = {
|
||||||
|
enabled: true,
|
||||||
|
from: '13:00',
|
||||||
|
to: '14:00',
|
||||||
|
days: ALL_DAYS
|
||||||
|
}
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T13:30:00'))).toBe(true)
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T12:59:00'))).toBe(false)
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T14:00:00'))).toBe(false) // exclusive end
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wrap-around window 22:00 → 08:00', () => {
|
||||||
|
const qh: QuietHours = {
|
||||||
|
enabled: true,
|
||||||
|
from: '22:00',
|
||||||
|
to: '08:00',
|
||||||
|
days: ALL_DAYS
|
||||||
|
}
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T23:00:00'))).toBe(true)
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T02:00:00'))).toBe(true)
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T07:59:00'))).toBe(true)
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T08:00:00'))).toBe(false)
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T15:00:00'))).toBe(false)
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T21:59:00'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('day filtering: window inactive on excluded days', () => {
|
||||||
|
const qh: QuietHours = {
|
||||||
|
enabled: true,
|
||||||
|
from: '13:00',
|
||||||
|
to: '14:00',
|
||||||
|
days: [1, 2, 3, 4, 5] // weekdays only
|
||||||
|
}
|
||||||
|
// 2026-05-17 is Sunday (day 0)
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T13:30:00'))).toBe(false)
|
||||||
|
// 2026-05-18 is Monday (day 1)
|
||||||
|
expect(isQuietAt(qh, at('2026-05-18T13:30:00'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zero-length window (from === to) is never quiet', () => {
|
||||||
|
const qh: QuietHours = {
|
||||||
|
enabled: true,
|
||||||
|
from: '12:00',
|
||||||
|
to: '12:00',
|
||||||
|
days: ALL_DAYS
|
||||||
|
}
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T12:00:00'))).toBe(false)
|
||||||
|
expect(isQuietAt(qh, at('2026-05-17T12:00:01'))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,6 +11,20 @@ export type Exercise = {
|
|||||||
|
|
||||||
export type NotificationMode = 'toast' | 'modal' | 'both'
|
export type NotificationMode = 'toast' | 'modal' | 'both'
|
||||||
export type Theme = 'light' | 'dark' | 'system'
|
export type Theme = 'light' | 'dark' | 'system'
|
||||||
|
export type Language = 'ru' | 'en'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hours when reminders are silenced. `from`/`to` are "HH:MM" 24h strings,
|
||||||
|
* `days` are weekday indices 0=Sun..6=Sat. Empty `days` = applies every day.
|
||||||
|
* If `to <= from` the window wraps across midnight (e.g. 22:00 → 07:00).
|
||||||
|
*/
|
||||||
|
export type QuietHours = {
|
||||||
|
enabled: boolean
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
/** Days when the quiet window is active. */
|
||||||
|
days: number[]
|
||||||
|
}
|
||||||
|
|
||||||
export type Settings = {
|
export type Settings = {
|
||||||
globalEnabled: boolean
|
globalEnabled: boolean
|
||||||
@@ -20,7 +34,9 @@ export type Settings = {
|
|||||||
minimizeToTray: boolean
|
minimizeToTray: boolean
|
||||||
startMinimized: boolean
|
startMinimized: boolean
|
||||||
theme: Theme
|
theme: Theme
|
||||||
|
language: Language
|
||||||
snoozeMinutes: number
|
snoozeMinutes: number
|
||||||
|
quietHours: QuietHours
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
@@ -28,6 +44,18 @@ export type AppState = {
|
|||||||
settings: Settings
|
settings: Settings
|
||||||
challenges: Challenge[]
|
challenges: Challenge[]
|
||||||
gamesEnabled: Partial<Record<GameId, boolean>>
|
gamesEnabled: Partial<Record<GameId, boolean>>
|
||||||
|
history?: HistoryEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistoryAction = 'done' | 'skip' | 'snooze'
|
||||||
|
|
||||||
|
export type HistoryEntry = {
|
||||||
|
/** ms epoch */
|
||||||
|
ts: number
|
||||||
|
exerciseId: string
|
||||||
|
action: HistoryAction
|
||||||
|
/** When user did less than planned. Only meaningful for `done`. */
|
||||||
|
actualReps?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Tick = {
|
export type Tick = {
|
||||||
@@ -71,6 +99,19 @@ export const STAT_LABELS: Record<GameStat, string> = {
|
|||||||
duration_min: 'минут матча'
|
duration_min: 'минут матча'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const STAT_LABELS_EN: Record<GameStat, string> = {
|
||||||
|
deaths: 'deaths',
|
||||||
|
kills: 'kills',
|
||||||
|
assists: 'assists',
|
||||||
|
last_hits: 'last hits',
|
||||||
|
denies: 'denies',
|
||||||
|
duration_min: 'match minutes'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statLabel(stat: GameStat, lang: Language): string {
|
||||||
|
return (lang === 'en' ? STAT_LABELS_EN : STAT_LABELS)[stat]
|
||||||
|
}
|
||||||
|
|
||||||
export type Challenge = {
|
export type Challenge = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -103,7 +144,10 @@ export type ChallengeResult = {
|
|||||||
exerciseName: string
|
exerciseName: string
|
||||||
reps: number
|
reps: number
|
||||||
statValue: number
|
statValue: number
|
||||||
|
/** Pre-localised label for backward compat; renderer prefers `stat`. */
|
||||||
statLabel: string
|
statLabel: string
|
||||||
|
/** Stat key; renderer uses this to localise on demand. */
|
||||||
|
stat?: GameStat
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MatchSummary = {
|
export type MatchSummary = {
|
||||||
@@ -122,7 +166,37 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
minimizeToTray: true,
|
minimizeToTray: true,
|
||||||
startMinimized: false,
|
startMinimized: false,
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
snoozeMinutes: 5
|
language: 'ru',
|
||||||
|
snoozeMinutes: 5,
|
||||||
|
quietHours: {
|
||||||
|
enabled: false,
|
||||||
|
from: '22:00',
|
||||||
|
to: '08:00',
|
||||||
|
days: [0, 1, 2, 3, 4, 5, 6]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if `now` falls inside the quiet window. Handles wrap-around
|
||||||
|
* windows (e.g. 22:00 → 08:00). Exposed from shared so both main scheduler
|
||||||
|
* and renderer settings UI can use the same logic.
|
||||||
|
*/
|
||||||
|
export function isQuietAt(qh: QuietHours, now: Date): boolean {
|
||||||
|
if (!qh.enabled) return false
|
||||||
|
const dow = now.getDay() // 0..6
|
||||||
|
if (qh.days.length > 0 && !qh.days.includes(dow)) return false
|
||||||
|
const [fh, fm] = qh.from.split(':').map(Number)
|
||||||
|
const [th, tm] = qh.to.split(':').map(Number)
|
||||||
|
const cur = now.getHours() * 60 + now.getMinutes()
|
||||||
|
const fromMin = fh * 60 + fm
|
||||||
|
const toMin = th * 60 + tm
|
||||||
|
if (fromMin === toMin) return false
|
||||||
|
if (fromMin < toMin) {
|
||||||
|
// Same-day window.
|
||||||
|
return cur >= fromMin && cur < toMin
|
||||||
|
}
|
||||||
|
// Wraps midnight: active if after `from` today OR before `to` today.
|
||||||
|
return cur >= fromMin || cur < toMin
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
|
export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
|
||||||
@@ -132,10 +206,10 @@ export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export type UpdaterStatus =
|
export type UpdaterStatus =
|
||||||
| { kind: 'idle' }
|
| { kind: 'idle'; lastCheckedAt?: number }
|
||||||
| { kind: 'unsupported'; reason: string }
|
| { kind: 'unsupported'; reason: string }
|
||||||
| { kind: 'checking' }
|
| { kind: 'checking' }
|
||||||
| { kind: 'not-available'; currentVersion: string }
|
| { kind: 'not-available'; currentVersion: string; lastCheckedAt?: number }
|
||||||
| { kind: 'available'; version: string; releaseDate?: string }
|
| { kind: 'available'; version: string; releaseDate?: string }
|
||||||
| {
|
| {
|
||||||
kind: 'downloading'
|
kind: 'downloading'
|
||||||
|
|||||||
Reference in New Issue
Block a user