Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f038e59e8 | ||
|
|
33e237948e | ||
|
|
f861af5db1 | ||
|
|
c9d4fc237e |
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",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.1",
|
||||
"description": "Exercise reminder — Windows desktop app",
|
||||
"main": "out/main/index.js",
|
||||
"author": "AnRil",
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/download/v${version}",
|
||||
"url": "https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/download/update-channel",
|
||||
"channel": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +1,78 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Локальный релиз: бамп версии → коммит → тег → push → сборка → upload в Gitea release.
|
||||
Локальный релиз: бамп версии -> коммит -> тег -> push -> сборка -> upload в Gitea.
|
||||
|
||||
.DESCRIPTION
|
||||
Один скрипт от и до. Если Gitea Actions не настроено, это рабочая альтернатива.
|
||||
Single-command release flow.
|
||||
|
||||
Каждый релиз публикует артефакты в ТРИ места:
|
||||
1. Тег vX.Y.Z (исторический архив + changelog)
|
||||
2. Тег update-channel (фиксированный URL для auto-updater)
|
||||
3. Bridge-теги, указанные в -BridgeTags (для миграции пользователей со
|
||||
старых версий, у которых запечён старый publish.url).
|
||||
|
||||
После того как все пользователи получили версию с новым (фиксированным)
|
||||
publish.url, аргумент -BridgeTags можно перестать указывать.
|
||||
|
||||
.PARAMETER Bump
|
||||
Какую часть semver инкрементировать: patch (по умолчанию), minor, major.
|
||||
Альтернатива — указать -Version явно.
|
||||
|
||||
.PARAMETER Version
|
||||
Точная версия (напр. "0.3.0"). Если задана, -Bump игнорируется.
|
||||
Точная версия (напр. "0.5.1"). Если задана, -Bump игнорируется.
|
||||
|
||||
.PARAMETER SkipBuild
|
||||
Пропустить сборку (если уже собрано вручную, .exe лежит в release/).
|
||||
|
||||
.PARAMETER BridgeTags
|
||||
Список старых тегов, в которые нужно ТАКЖЕ перезалить новые артефакты,
|
||||
чтобы пользователи на этих версиях нашли апдейт. Например: v0.4.0,v0.5.0.
|
||||
|
||||
.PARAMETER DryRun
|
||||
Показать что произойдёт, но ничего не делать.
|
||||
Показать что произойдёт, ничего не делая.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh scripts/release.ps1 -Bump minor
|
||||
pwsh scripts/release.ps1 -Version 0.3.0
|
||||
pwsh scripts/release.ps1 -Bump patch -DryRun
|
||||
pwsh scripts/release.ps1 -Bump patch
|
||||
pwsh scripts/release.ps1 -Version 0.5.1 -BridgeTags v0.4.0,v0.5.0
|
||||
|
||||
.NOTES
|
||||
Требует переменную окружения GITEA_TOKEN с правом write:repository
|
||||
(создаётся в Gitea: Settings → Applications → Generate New Token).
|
||||
Требует GITEA_TOKEN с правом write:repository.
|
||||
Канал 'update-channel' должен существовать на Gitea (создаётся однократно).
|
||||
#>
|
||||
param(
|
||||
[ValidateSet('patch', 'minor', 'major')]
|
||||
[string]$Bump = 'patch',
|
||||
[string]$Version,
|
||||
[switch]$SkipBuild,
|
||||
[string[]]$BridgeTags = @(),
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- Config ---------------------------------------------------------------
|
||||
$repoOwner = 'AnRil'
|
||||
$repoName = 'laude'
|
||||
$giteaHost = 'xn--90adajar8af4h.xn--p1ai/git'
|
||||
$apiBase = "https://$giteaHost/api/v1"
|
||||
$channelTag = 'update-channel'
|
||||
|
||||
# --- Pre-flight checks ---------------------------------------------------
|
||||
# --- Pre-flight ----------------------------------------------------------
|
||||
$root = Resolve-Path (Join-Path $PSScriptRoot '..')
|
||||
Set-Location $root
|
||||
|
||||
if (-not $env:GITEA_TOKEN -and -not $DryRun) {
|
||||
Write-Error 'GITEA_TOKEN не задан. Создай в Gitea Settings → Applications и export GITEA_TOKEN=...'
|
||||
Write-Error 'GITEA_TOKEN not set.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
$status = git status --porcelain
|
||||
if ($status) {
|
||||
Write-Error "Есть незакоммиченные изменения. Сначала закоммить или stash."
|
||||
Write-Error "Uncommitted changes. Commit or stash first."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$branch = git rev-parse --abbrev-ref HEAD
|
||||
if ($branch -ne 'main') {
|
||||
Write-Warning "Текущая ветка не main, а $branch. Продолжить? (Ctrl+C для отмены)"
|
||||
Read-Host 'Press Enter'
|
||||
Write-Warning "Branch is $branch, not main. Press Enter to continue or Ctrl+C to cancel."
|
||||
Read-Host
|
||||
}
|
||||
|
||||
# --- Compute next version ------------------------------------------------
|
||||
@@ -83,109 +94,75 @@ if ($Version) {
|
||||
|
||||
$tag = "v$next"
|
||||
Write-Host ""
|
||||
Write-Host "→ Release plan" -ForegroundColor Cyan
|
||||
Write-Host "Release plan" -ForegroundColor Cyan
|
||||
Write-Host " current : v$current"
|
||||
Write-Host " next : $tag"
|
||||
Write-Host " bump : $Bump"
|
||||
Write-Host " publish into : $tag, $channelTag$(if ($BridgeTags) { ', ' + ($BridgeTags -join ', ') })"
|
||||
Write-Host ""
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host '(dry run — exiting)' -ForegroundColor Yellow
|
||||
Write-Host '(dry run - exiting)' -ForegroundColor Yellow
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- Bump version in package.json ---------------------------------------
|
||||
Write-Host "→ Bumping package.json to $next…" -ForegroundColor Cyan
|
||||
$pkgJson = (Get-Content package.json -Raw) -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`""
|
||||
Set-Content -Path package.json -Value $pkgJson -NoNewline -Encoding utf8
|
||||
# --- Bump package.json --------------------------------------------------
|
||||
# IMPORTANT: read+write as UTF-8 WITHOUT BOM. PS5.1 defaults will (a) read the
|
||||
# file as CP1251 and mangle non-ASCII chars like em-dash, and (b) write back
|
||||
# with a BOM that breaks PostCSS / electron-builder reads of package.json.
|
||||
Write-Host "Bumping package.json to $next..." -ForegroundColor Cyan
|
||||
$pkgPath = Join-Path $root 'package.json'
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
$pkgJson = [System.IO.File]::ReadAllText($pkgPath, $utf8NoBom)
|
||||
$pkgJson = $pkgJson -replace "`"version`":\s*`"$current`"", "`"version`": `"$next`""
|
||||
[System.IO.File]::WriteAllText($pkgPath, $pkgJson, $utf8NoBom)
|
||||
|
||||
git add package.json
|
||||
git commit -m "chore(release): $tag"
|
||||
|
||||
# --- Build (typecheck + tests + dist) ------------------------------------
|
||||
# --- Quality gates ------------------------------------------------------
|
||||
if (-not $SkipBuild) {
|
||||
Write-Host "→ Running typecheck…" -ForegroundColor Cyan
|
||||
Write-Host "Typecheck..." -ForegroundColor Cyan
|
||||
npm run typecheck
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
Write-Host "→ Running tests…" -ForegroundColor Cyan
|
||||
Write-Host "Tests..." -ForegroundColor Cyan
|
||||
npm run test:run
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
Write-Host "→ Building installer (npm run dist)…" -ForegroundColor Cyan
|
||||
Write-Host "Building installer..." -ForegroundColor Cyan
|
||||
npm run dist
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
}
|
||||
|
||||
# --- Verify artifacts exist ---------------------------------------------
|
||||
# --- Verify artifacts ---------------------------------------------------
|
||||
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$next.exe"
|
||||
$blockmap = "$installer.blockmap"
|
||||
$manifest = Join-Path 'release' 'latest.yml'
|
||||
foreach ($f in @($installer, $blockmap, $manifest)) {
|
||||
if (-not (Test-Path $f)) {
|
||||
Write-Error "Не найден артефакт: $f"
|
||||
Write-Error "Artifact missing: $f"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Tag + push ----------------------------------------------------------
|
||||
Write-Host "→ Tagging $tag and pushing…" -ForegroundColor Cyan
|
||||
# --- Tag + push ---------------------------------------------------------
|
||||
Write-Host "Tagging $tag and pushing..." -ForegroundColor Cyan
|
||||
git tag -a $tag -m "Release $tag"
|
||||
git push origin main
|
||||
git push origin $tag
|
||||
|
||||
# --- Create release via Gitea API ----------------------------------------
|
||||
Write-Host "→ Creating Gitea release $tag…" -ForegroundColor Cyan
|
||||
$headers = @{
|
||||
Authorization = "token $env:GITEA_TOKEN"
|
||||
Accept = 'application/json'
|
||||
}
|
||||
# --- Upload to all target releases --------------------------------------
|
||||
$uploadScript = Join-Path $PSScriptRoot 'upload-release-assets.ps1'
|
||||
|
||||
# Release notes from commits since previous tag
|
||||
$prev = git describe --tags --abbrev=0 "$tag^" 2>$null
|
||||
if ($prev) {
|
||||
$log = git log --pretty=format:"- %s" "$prev..$tag" | Out-String
|
||||
} else {
|
||||
$log = git log --pretty=format:"- %s" "$tag" | Out-String
|
||||
}
|
||||
$body = @"
|
||||
### Изменения
|
||||
|
||||
$log
|
||||
|
||||
---
|
||||
|
||||
**Установщик ниже** — запустить и следовать мастеру. Если приложение уже стояло — обновится поверх, настройки сохранятся.
|
||||
"@
|
||||
|
||||
$releaseBody = @{
|
||||
tag_name = $tag
|
||||
name = "Exercise Reminder $tag"
|
||||
body = $body
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
} | ConvertTo-Json -Depth 5
|
||||
|
||||
$release = Invoke-RestMethod `
|
||||
-Uri "$apiBase/repos/$repoOwner/$repoName/releases" `
|
||||
-Method Post `
|
||||
-Headers $headers `
|
||||
-Body $releaseBody `
|
||||
-ContentType 'application/json'
|
||||
|
||||
Write-Host " Release id: $($release.id)" -ForegroundColor DarkGray
|
||||
|
||||
# --- Upload assets -------------------------------------------------------
|
||||
foreach ($asset in @($installer, $blockmap, $manifest)) {
|
||||
$name = Split-Path $asset -Leaf
|
||||
Write-Host "→ Uploading $name…" -ForegroundColor Cyan
|
||||
$uri = "$apiBase/repos/$repoOwner/$repoName/releases/$($release.id)/assets?name=$([uri]::EscapeDataString($name))"
|
||||
Invoke-RestMethod `
|
||||
-Uri $uri `
|
||||
-Method Post `
|
||||
-Headers $headers `
|
||||
-InFile $asset `
|
||||
-ContentType 'application/octet-stream' | Out-Null
|
||||
$targets = @($tag, $channelTag) + $BridgeTags
|
||||
foreach ($target in $targets) {
|
||||
Write-Host ""
|
||||
Write-Host "==> Uploading $next artifacts into release '$target'" -ForegroundColor Cyan
|
||||
& powershell -ExecutionPolicy Bypass -File $uploadScript -Tag $target -AssetVersion $next
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Upload to '$target' failed (exit $LASTEXITCODE)"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
}
|
||||
|
||||
$releaseUrl = "https://$giteaHost/$repoOwner/$repoName/releases/tag/$tag"
|
||||
@@ -193,4 +170,4 @@ Write-Host ""
|
||||
Write-Host "Release published" -ForegroundColor Green
|
||||
Write-Host " $releaseUrl"
|
||||
Write-Host ""
|
||||
Write-Host "Auto-updater подхватит обновление на установленных копиях в течение ~6 часов."
|
||||
Write-Host "Auto-updater will pick up the new version within ~1 hour on all installed copies."
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Upload pre-built NSIS artifacts to an existing Gitea release.
|
||||
Upload pre-built NSIS artifacts to a Gitea release.
|
||||
|
||||
.DESCRIPTION
|
||||
Use when the tag v* is already pushed (e.g. release.ps1 succeeded up to
|
||||
push but failed on upload, or release was created manually without assets).
|
||||
If a release for the tag does not exist yet, it is created. If it exists,
|
||||
same-name assets are replaced.
|
||||
Uploads installer + blockmap + latest.yml to the release identified by -Tag.
|
||||
If the release does not exist it is created (only for semver-looking tags;
|
||||
for non-semver tags like 'update-channel' the release must exist already).
|
||||
Same-named existing assets are replaced.
|
||||
|
||||
.PARAMETER Tag
|
||||
Version tag, e.g. v0.3.0. Defaults to v<package.json version>.
|
||||
Release tag to upload INTO. May be a version tag (v0.5.1) or a channel
|
||||
tag (update-channel). Defaults to v<package.json version>.
|
||||
|
||||
.PARAMETER AssetVersion
|
||||
Version of the artifacts being uploaded (e.g. 0.5.1). Defaults to the
|
||||
numeric part of -Tag. Specify explicitly when uploading version-X.Y.Z
|
||||
artifacts into a non-version tag (channel or bridge).
|
||||
|
||||
.EXAMPLE
|
||||
pwsh scripts/upload-release-assets.ps1
|
||||
pwsh scripts/upload-release-assets.ps1 -Tag v0.3.0
|
||||
pwsh scripts/upload-release-assets.ps1 -Tag v0.5.0
|
||||
pwsh scripts/upload-release-assets.ps1 -Tag update-channel -AssetVersion 0.5.1
|
||||
pwsh scripts/upload-release-assets.ps1 -Tag v0.4.0 -AssetVersion 0.5.1
|
||||
#>
|
||||
param(
|
||||
[string]$Tag
|
||||
[string]$Tag,
|
||||
[string]$AssetVersion
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -35,10 +44,18 @@ $root = Resolve-Path (Join-Path $PSScriptRoot '..')
|
||||
Set-Location $root
|
||||
|
||||
if (-not $Tag) {
|
||||
$version = (Get-Content package.json | ConvertFrom-Json).version
|
||||
$Tag = "v$version"
|
||||
$pkgVersion = (Get-Content package.json | ConvertFrom-Json).version
|
||||
$Tag = "v$pkgVersion"
|
||||
}
|
||||
$version = $Tag.TrimStart('v')
|
||||
if (-not $AssetVersion) {
|
||||
# Derive from tag when possible (vX.Y.Z → X.Y.Z); otherwise read package.json.
|
||||
if ($Tag -match '^v\d+\.\d+\.\d+') {
|
||||
$AssetVersion = $Tag.TrimStart('v')
|
||||
} else {
|
||||
$AssetVersion = (Get-Content package.json | ConvertFrom-Json).version
|
||||
}
|
||||
}
|
||||
$version = $AssetVersion
|
||||
|
||||
$installer = Join-Path 'release' "Exercise-Reminder-Setup-$version.exe"
|
||||
$blockmap = "$installer.blockmap"
|
||||
@@ -66,6 +83,10 @@ try {
|
||||
Write-Host " Found release id=$($release.id)" -ForegroundColor DarkGray
|
||||
} catch {
|
||||
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
|
||||
if ($Tag -notmatch '^v\d+\.\d+\.\d+') {
|
||||
Write-Error "Release '$Tag' not found and tag is not semver. Create it manually on Gitea (e.g. 'update-channel' is a one-time setup)."
|
||||
exit 1
|
||||
}
|
||||
Write-Host " Not found, creating new release..." -ForegroundColor DarkGray
|
||||
|
||||
$prev = $null
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { Challenge, Exercise, GameId, Settings } from '@shared/types'
|
||||
import {
|
||||
addChallenge,
|
||||
addExercise,
|
||||
clearHistory,
|
||||
deleteChallenge,
|
||||
deleteExercise,
|
||||
getHistory,
|
||||
getState,
|
||||
markDone,
|
||||
setGameEnabled,
|
||||
@@ -73,11 +75,14 @@ export function registerIpc(): void {
|
||||
return ex
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.markDone, (_e, id: string) => {
|
||||
const ex = markDone(id)
|
||||
ipcMain.handle(
|
||||
IPC.markDone,
|
||||
(_e, id: string, actualReps?: number) => {
|
||||
const ex = markDone(id, actualReps)
|
||||
broadcastState()
|
||||
return ex
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => {
|
||||
const ex = snooze(id, minutes)
|
||||
@@ -213,4 +218,10 @@ export function registerIpc(): void {
|
||||
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
|
||||
ipcMain.handle(IPC.updaterDownload, () => downloadUpdate())
|
||||
ipcMain.handle(IPC.updaterInstall, () => quitAndInstall())
|
||||
|
||||
// History
|
||||
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
|
||||
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) =>
|
||||
clearHistory(beforeTs)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { powerMonitor, BrowserWindow } from 'electron'
|
||||
import { IPC } from '@shared/ipc'
|
||||
import type { Tick } from '@shared/types'
|
||||
import { isQuietAt } from '@shared/types'
|
||||
import { getExercises, getSettings, updateExercise } from './store'
|
||||
import { fireReminder } from './notifications'
|
||||
|
||||
@@ -15,12 +16,15 @@ function checkDueExercises(): void {
|
||||
const settings = getSettings()
|
||||
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 exercises = getExercises()
|
||||
for (const ex of exercises) {
|
||||
if (!ex.enabled) continue
|
||||
if (ex.nextFireAt <= now) {
|
||||
// Fire once, reschedule from now (drop missed intervals).
|
||||
const updated = updateExercise(ex.id, {
|
||||
nextFireAt: now + ex.intervalMinutes * 60_000
|
||||
})
|
||||
|
||||
@@ -8,10 +8,15 @@ import {
|
||||
DEFAULT_SETTINGS,
|
||||
Exercise,
|
||||
GameId,
|
||||
HistoryAction,
|
||||
HistoryEntry,
|
||||
SAMPLE_EXERCISES,
|
||||
Settings
|
||||
} 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 storePath = ''
|
||||
let pendingWrite: NodeJS.Timeout | null = null
|
||||
@@ -56,7 +61,8 @@ function makeInitial(): AppState {
|
||||
enabled: false
|
||||
}
|
||||
],
|
||||
gamesEnabled: {}
|
||||
gamesEnabled: {},
|
||||
history: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +80,49 @@ function load(): AppState {
|
||||
exercises: parsed.exercises ?? [],
|
||||
settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) },
|
||||
challenges: parsed.challenges ?? [],
|
||||
gamesEnabled: parsed.gamesEnabled ?? {}
|
||||
gamesEnabled: parsed.gamesEnabled ?? {},
|
||||
history: parsed.history ?? []
|
||||
}
|
||||
} catch {
|
||||
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 {
|
||||
if (!cache) return
|
||||
writeFileSync(getStorePath(), JSON.stringify(cache, null, 2), 'utf-8')
|
||||
@@ -155,12 +197,16 @@ export function deleteExercise(id: string): boolean {
|
||||
return changed
|
||||
}
|
||||
|
||||
export function markDone(id: string): Exercise | undefined {
|
||||
export function markDone(
|
||||
id: string,
|
||||
actualReps?: number
|
||||
): Exercise | undefined {
|
||||
const state = getState()
|
||||
const ex = state.exercises.find((e) => e.id === id)
|
||||
if (!ex) return undefined
|
||||
ex.lastDoneAt = Date.now()
|
||||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||
appendHistory(id, 'done', actualReps)
|
||||
scheduleWrite()
|
||||
return ex
|
||||
}
|
||||
@@ -170,6 +216,7 @@ export function snooze(id: string, minutes: number): Exercise | undefined {
|
||||
const ex = state.exercises.find((e) => e.id === id)
|
||||
if (!ex) return undefined
|
||||
ex.nextFireAt = Date.now() + minutes * 60_000
|
||||
appendHistory(id, 'snooze')
|
||||
scheduleWrite()
|
||||
return ex
|
||||
}
|
||||
@@ -179,6 +226,7 @@ export function skip(id: string): Exercise | undefined {
|
||||
const ex = state.exercises.find((e) => e.id === id)
|
||||
if (!ex) return undefined
|
||||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||
appendHistory(id, 'skip')
|
||||
scheduleWrite()
|
||||
return ex
|
||||
}
|
||||
|
||||
@@ -4,16 +4,31 @@ import { IPC } from '@shared/ipc'
|
||||
import type { UpdaterStatus } from '@shared/types'
|
||||
|
||||
let currentStatus: UpdaterStatus = { kind: 'idle' }
|
||||
let lastCheckedAt: number | undefined
|
||||
let wired = false
|
||||
let checkInterval: NodeJS.Timeout | null = null
|
||||
|
||||
// User-initiated checks surface errors. Background checks stay quiet to avoid
|
||||
// the red banner on transient network blips (504s, DNS, captive portals).
|
||||
let silentMode = false
|
||||
|
||||
const CHECK_INTERVAL_MS = 60 * 60 * 1000 // every hour
|
||||
const BOOT_DELAY_MS = 5_000
|
||||
// Boot retry: if the first check fails (e.g. network not yet up), retry a few
|
||||
// times with exponential backoff before giving up until the hourly tick.
|
||||
const BOOT_RETRY_DELAYS = [30_000, 120_000, 300_000] // 30s, 2min, 5min
|
||||
|
||||
export function getUpdaterStatus(): UpdaterStatus {
|
||||
return currentStatus
|
||||
}
|
||||
|
||||
function setStatus(s: UpdaterStatus): void {
|
||||
// Preserve lastCheckedAt across status transitions where applicable.
|
||||
if (s.kind === 'not-available' || s.kind === 'idle') {
|
||||
if (lastCheckedAt && !('lastCheckedAt' in s)) {
|
||||
;(s as { lastCheckedAt?: number }).lastCheckedAt = lastCheckedAt
|
||||
}
|
||||
}
|
||||
currentStatus = s
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) win.webContents.send(IPC.evtUpdaterStatus, s)
|
||||
@@ -38,9 +53,14 @@ export function initUpdater(): void {
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
autoUpdater.allowDowngrade = false
|
||||
|
||||
autoUpdater.on('checking-for-update', () => setStatus({ kind: 'checking' }))
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
// Don't replace the prior status with "checking" during silent polls — the
|
||||
// UI would briefly flicker for users opening Settings during a tick.
|
||||
if (!silentMode) setStatus({ kind: 'checking' })
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
lastCheckedAt = Date.now()
|
||||
setStatus({
|
||||
kind: 'available',
|
||||
version: info.version,
|
||||
@@ -50,7 +70,12 @@ export function initUpdater(): void {
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
setStatus({ kind: 'not-available', currentVersion: app.getVersion() })
|
||||
lastCheckedAt = Date.now()
|
||||
setStatus({
|
||||
kind: 'not-available',
|
||||
currentVersion: app.getVersion(),
|
||||
lastCheckedAt
|
||||
})
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (p) => {
|
||||
@@ -68,23 +93,43 @@ export function initUpdater(): void {
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
setStatus({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (silentMode) {
|
||||
// Background check failed — keep previous status, don't show red banner.
|
||||
// Will retry on the next hourly tick.
|
||||
console.warn('[updater] silent check failed:', message)
|
||||
return
|
||||
}
|
||||
setStatus({ kind: 'error', message })
|
||||
})
|
||||
|
||||
// First check on boot (slight delay so window has time to subscribe).
|
||||
// First check on boot with retry-on-failure.
|
||||
setTimeout(() => {
|
||||
void checkForUpdates()
|
||||
}, 5_000)
|
||||
void bootCheckWithRetry()
|
||||
}, BOOT_DELAY_MS)
|
||||
|
||||
// Periodic re-check
|
||||
// Periodic re-check (silent).
|
||||
checkInterval = setInterval(() => {
|
||||
void checkForUpdates()
|
||||
void checkForUpdates({ silent: true })
|
||||
}, CHECK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
async function bootCheckWithRetry(): Promise<void> {
|
||||
for (let attempt = 0; attempt <= BOOT_RETRY_DELAYS.length; attempt++) {
|
||||
await checkForUpdates({ silent: true })
|
||||
if (
|
||||
currentStatus.kind === 'available' ||
|
||||
currentStatus.kind === 'not-available' ||
|
||||
currentStatus.kind === 'downloaded'
|
||||
) {
|
||||
return // success
|
||||
}
|
||||
const delay = BOOT_RETRY_DELAYS[attempt]
|
||||
if (delay === undefined) return // exhausted retries
|
||||
await new Promise((r) => setTimeout(r, delay))
|
||||
}
|
||||
}
|
||||
|
||||
export function stopUpdater(): void {
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval)
|
||||
@@ -92,15 +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
|
||||
silentMode = opts.silent ?? false
|
||||
try {
|
||||
await autoUpdater.checkForUpdates()
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (silentMode) {
|
||||
console.warn('[updater] silent check failed (sync):', message)
|
||||
} else {
|
||||
setStatus({ kind: 'error', message })
|
||||
}
|
||||
} finally {
|
||||
silentMode = false
|
||||
}
|
||||
return currentStatus
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
Exercise,
|
||||
GameId,
|
||||
GameStatus,
|
||||
HistoryEntry,
|
||||
MatchSummary,
|
||||
Settings,
|
||||
Tick,
|
||||
@@ -33,7 +34,8 @@ const api = {
|
||||
ipcRenderer.invoke(IPC.deleteExercise, id),
|
||||
toggleExercise: (id: string, enabled: boolean): Promise<Exercise> =>
|
||||
ipcRenderer.invoke(IPC.toggleExercise, id, enabled),
|
||||
markDone: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.markDone, id),
|
||||
markDone: (id: string, actualReps?: number): Promise<Exercise> =>
|
||||
ipcRenderer.invoke(IPC.markDone, id, actualReps),
|
||||
snooze: (id: string, minutes: number): Promise<Exercise> =>
|
||||
ipcRenderer.invoke(IPC.snooze, id, minutes),
|
||||
skip: (id: string): Promise<Exercise> => ipcRenderer.invoke(IPC.skip, id),
|
||||
@@ -87,6 +89,12 @@ const api = {
|
||||
updaterDownload: (): Promise<void> => ipcRenderer.invoke(IPC.updaterDownload),
|
||||
updaterInstall: (): Promise<void> => ipcRenderer.invoke(IPC.updaterInstall),
|
||||
|
||||
// History
|
||||
getHistory: (sinceMs?: number): Promise<HistoryEntry[]> =>
|
||||
ipcRenderer.invoke(IPC.getHistory, sinceMs),
|
||||
clearHistory: (beforeTs?: number): Promise<number> =>
|
||||
ipcRenderer.invoke(IPC.clearHistory, beforeTs),
|
||||
|
||||
onTick: (h: Handler<Tick[]>): Unsub => on(IPC.evtTick, h),
|
||||
onFire: (h: Handler<Exercise>): Unsub => on(IPC.evtFire, h),
|
||||
onMatchEnd: (h: Handler<MatchSummary>): Unsub => on(IPC.evtMatchEnd, h),
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Check, Clock, X, Trophy, Frown, Gamepad2 } from 'lucide-react'
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
X,
|
||||
Trophy,
|
||||
Frown,
|
||||
Gamepad2,
|
||||
Minus,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
Exercise,
|
||||
MatchSummary,
|
||||
@@ -113,8 +122,16 @@ function ExerciseReminder({
|
||||
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> {
|
||||
await window.api.markDone(exercise.id)
|
||||
// Only pass actualReps when user adjusted — otherwise leave undefined
|
||||
// so history records the full planned value cleanly.
|
||||
await window.api.markDone(
|
||||
exercise.id,
|
||||
adjusted ? actualReps : undefined
|
||||
)
|
||||
onClose()
|
||||
}
|
||||
async function snooze(): Promise<void> {
|
||||
@@ -125,6 +142,8 @@ function ExerciseReminder({
|
||||
await window.api.skip(exercise.id)
|
||||
onClose()
|
||||
}
|
||||
const dec = (): void => setActualReps((n) => Math.max(0, n - 1))
|
||||
const inc = (): void => setActualReps((n) => n + 1)
|
||||
|
||||
return (
|
||||
<div className="reminder-shell flex flex-col h-full">
|
||||
@@ -157,14 +176,41 @@ function ExerciseReminder({
|
||||
{exercise.name}
|
||||
</h1>
|
||||
|
||||
<div className="inline-flex items-baseline gap-2 font-mono-num">
|
||||
<span className="text-[56px] font-semibold tracking-tight text-text leading-none">
|
||||
{exercise.reps}
|
||||
{/* Reps stepper — tap +/− if you did less than planned. */}
|
||||
<div className="inline-flex items-center gap-3 select-none">
|
||||
<button
|
||||
onClick={dec}
|
||||
className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all"
|
||||
aria-label="−"
|
||||
>
|
||||
<Minus size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
<div className="inline-flex items-baseline gap-2 font-mono-num min-w-[120px] justify-center">
|
||||
<span
|
||||
className={[
|
||||
'text-[56px] font-semibold tracking-tight leading-none',
|
||||
adjusted ? 'text-accent' : 'text-text'
|
||||
].join(' ')}
|
||||
>
|
||||
{actualReps}
|
||||
</span>
|
||||
<span className="text-[15px] text-text/65 font-semibold">
|
||||
{t('reminder.reps')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={inc}
|
||||
className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all"
|
||||
aria-label="+"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
{adjusted && (
|
||||
<div className="text-[12px] text-accent mt-2 font-medium">
|
||||
{t('reminder.partial', { actual: actualReps, planned: exercise.reps })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-[13px] text-text/65 mt-4 inline-flex items-center gap-1.5 font-medium">
|
||||
<Clock size={12} strokeWidth={2.4} />
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -10,9 +10,18 @@ import {
|
||||
import { motion } from 'framer-motion'
|
||||
import { Button } from './ui/Button'
|
||||
import { Card } from './ui/Card'
|
||||
import { useT } from '../i18n'
|
||||
import { useT, type TFn } from '../i18n'
|
||||
import type { UpdaterStatus } from '@shared/types'
|
||||
|
||||
function formatChecked(ts: number, t: TFn): string {
|
||||
const diffMs = Date.now() - ts
|
||||
const diffMin = Math.max(0, Math.round(diffMs / 60_000))
|
||||
if (diffMin < 1) return t('updater.checked.just_now')
|
||||
if (diffMin < 60) return t('updater.checked.minutes_ago', { n: diffMin })
|
||||
const diffH = Math.round(diffMin / 60)
|
||||
return t('updater.checked.hours_ago', { n: diffH })
|
||||
}
|
||||
|
||||
export function UpdaterCard(): JSX.Element {
|
||||
const [status, setStatus] = useState<UpdaterStatus>({ kind: 'idle' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -96,12 +105,18 @@ function Body({
|
||||
)
|
||||
}
|
||||
if (status.kind === 'not-available') {
|
||||
const subtitle = status.lastCheckedAt
|
||||
? t('updater.up_to_date.subtitle_checked', {
|
||||
v: status.currentVersion,
|
||||
when: formatChecked(status.lastCheckedAt, t)
|
||||
})
|
||||
: t('updater.up_to_date.subtitle', { v: status.currentVersion })
|
||||
return (
|
||||
<Cell
|
||||
tone="success"
|
||||
icon={<CheckCircle2 size={16} strokeWidth={2.4} />}
|
||||
title={t('updater.up_to_date')}
|
||||
subtitle={t('updater.up_to_date.subtitle', { v: status.currentVersion })}
|
||||
subtitle={subtitle}
|
||||
action={
|
||||
<Button variant="tinted" size="sm" onClick={onCheck} disabled={busy}>
|
||||
<RefreshCw size={13} strokeWidth={2.5} /> {t('btn.check')}
|
||||
|
||||
@@ -52,6 +52,10 @@ export const ru: Dict = {
|
||||
'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': 'на паузе',
|
||||
@@ -133,6 +137,7 @@ export const ru: Dict = {
|
||||
'settings.kicker': 'Конфигурация',
|
||||
'settings.title': 'Настройки',
|
||||
'settings.section.reminders': 'Напоминания',
|
||||
'settings.section.quiet': 'Тихие часы',
|
||||
'settings.section.window': 'Окно и трей',
|
||||
'settings.section.appearance': 'Внешний вид',
|
||||
'settings.section.language': 'Язык',
|
||||
@@ -151,6 +156,12 @@ export const ru: Dict = {
|
||||
'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',
|
||||
@@ -174,6 +185,11 @@ export const ru: Dict = {
|
||||
'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} МБ/с',
|
||||
@@ -188,6 +204,7 @@ export const ru: Dict = {
|
||||
'reminder.subkicker': 'Двигайся',
|
||||
'reminder.reps': 'раз',
|
||||
'reminder.next_in': 'Следующее через {interval}',
|
||||
'reminder.partial': 'Засчитаем {actual} из {planned}',
|
||||
'reminder.btn.done': 'Готово',
|
||||
'match.title.won': 'Победа',
|
||||
'match.title.lost': 'Поражение',
|
||||
@@ -254,6 +271,10 @@ export const en: Dict = {
|
||||
'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',
|
||||
@@ -334,6 +355,7 @@ export const en: Dict = {
|
||||
'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',
|
||||
@@ -352,6 +374,12 @@ export const en: Dict = {
|
||||
'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',
|
||||
@@ -375,6 +403,11 @@ export const en: Dict = {
|
||||
'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',
|
||||
@@ -389,6 +422,7 @@ export const en: Dict = {
|
||||
'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',
|
||||
|
||||
@@ -9,6 +9,7 @@ export function getDict(lang: Language): Dict {
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Plus, Pause, Play, Flame, Activity } from 'lucide-react'
|
||||
import { Plus, Pause, Play, Flame, Activity, TrendingUp } from 'lucide-react'
|
||||
import { useAppStore } from '../store/appStore'
|
||||
import { ExerciseCard } from '../components/ExerciseCard'
|
||||
import { ExerciseEditor } from '../components/ExerciseEditor'
|
||||
import { HistoryHeatmap } from '../components/HistoryHeatmap'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import type { Exercise } from '@shared/types'
|
||||
import type { Exercise, HistoryEntry } from '@shared/types'
|
||||
import { formatCountdown } from '../lib/format'
|
||||
import { useT } from '../i18n'
|
||||
import { currentStreak, dailyReps, todayKey } from '../lib/history'
|
||||
|
||||
export default function Dashboard(): JSX.Element {
|
||||
const state = useAppStore((s) => s.state)
|
||||
@@ -20,6 +22,18 @@ export default function Dashboard(): JSX.Element {
|
||||
const settings = state?.settings
|
||||
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 enabled = exercises.filter((e) => e.enabled)
|
||||
const next = enabled
|
||||
@@ -94,13 +108,20 @@ export default function Dashboard(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
|
||||
<HeroStat
|
||||
tone="accent"
|
||||
label={t('dashboard.stat.active')}
|
||||
value={`${stats.active}`}
|
||||
subvalue={t('dashboard.stat.active.of', { total: stats.total })}
|
||||
icon={<Activity size={14} strokeWidth={2.6} />}
|
||||
label={t('dashboard.stat.today_done')}
|
||||
value={`${todayDone}`}
|
||||
subvalue={t('dashboard.stat.today_done.subtitle')}
|
||||
icon={<TrendingUp size={14} strokeWidth={2.6} />}
|
||||
/>
|
||||
<HeroStat
|
||||
tone={streak > 0 ? 'warning' : 'muted'}
|
||||
label={t('dashboard.stat.streak')}
|
||||
value={`${streak}`}
|
||||
subvalue={t('dashboard.stat.streak.subtitle', { n: streak })}
|
||||
icon={<Flame size={14} strokeWidth={2.6} />}
|
||||
/>
|
||||
<HeroStat
|
||||
tone="info"
|
||||
@@ -117,7 +138,7 @@ export default function Dashboard(): JSX.Element {
|
||||
? t('dashboard.stat.next.subtitle_paused')
|
||||
: t('dashboard.stat.next.subtitle_running')
|
||||
}
|
||||
icon={<Flame size={14} strokeWidth={2.6} />}
|
||||
icon={<Activity size={14} strokeWidth={2.6} />}
|
||||
/>
|
||||
<HeroStat
|
||||
tone={gamesEnabled ? 'success' : 'muted'}
|
||||
@@ -143,6 +164,16 @@ export default function Dashboard(): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<HistoryHeatmap
|
||||
history={history}
|
||||
exercises={exercises}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paused && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
@@ -214,7 +245,7 @@ function HeroStat({
|
||||
subvalue,
|
||||
icon
|
||||
}: {
|
||||
tone: 'accent' | 'info' | 'success' | 'muted'
|
||||
tone: 'accent' | 'info' | 'success' | 'warning' | 'muted'
|
||||
label: string
|
||||
value: string
|
||||
subvalue?: string
|
||||
@@ -227,6 +258,8 @@ function HeroStat({
|
||||
? 'bg-info'
|
||||
: tone === 'success'
|
||||
? 'bg-success'
|
||||
: tone === 'warning'
|
||||
? 'bg-warning'
|
||||
: 'bg-text/40'
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useT } from '../i18n'
|
||||
import type {
|
||||
Language,
|
||||
NotificationMode,
|
||||
QuietHours,
|
||||
Settings as SettingsType,
|
||||
Theme
|
||||
} from '@shared/types'
|
||||
@@ -91,6 +92,29 @@ export default function SettingsPage(): JSX.Element {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<SectionHeader title={t('settings.section.quiet')} />
|
||||
<Card className="mb-6">
|
||||
<ToggleRow
|
||||
label={t('settings.quiet.enabled.label')}
|
||||
hint={t('settings.quiet.enabled.hint')}
|
||||
checked={settings.quietHours.enabled}
|
||||
onChange={(v) =>
|
||||
patch({ quietHours: { ...settings.quietHours, enabled: v } })
|
||||
}
|
||||
/>
|
||||
<QuietTimesRow
|
||||
qh={settings.quietHours}
|
||||
onChange={(qh) => patch({ quietHours: qh })}
|
||||
disabled={!settings.quietHours.enabled}
|
||||
/>
|
||||
<QuietDaysRow
|
||||
qh={settings.quietHours}
|
||||
onChange={(qh) => patch({ quietHours: qh })}
|
||||
disabled={!settings.quietHours.enabled}
|
||||
last
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<SectionHeader title={t('settings.section.window')} />
|
||||
<Card className="mb-6">
|
||||
<ToggleRow
|
||||
@@ -168,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({
|
||||
label,
|
||||
hint,
|
||||
|
||||
@@ -42,6 +42,10 @@ export const IPC = {
|
||||
updaterDownload: 'updater:download',
|
||||
updaterInstall: 'updater:install',
|
||||
|
||||
// History
|
||||
getHistory: 'history:get',
|
||||
clearHistory: 'history:clear',
|
||||
|
||||
// events from main → renderer
|
||||
evtTick: 'evt:tick',
|
||||
evtFire: 'evt:fire',
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,19 @@ export type NotificationMode = 'toast' | 'modal' | 'both'
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
export type Language = 'ru' | 'en'
|
||||
|
||||
/**
|
||||
* Hours when reminders are silenced. `from`/`to` are "HH:MM" 24h strings,
|
||||
* `days` are weekday indices 0=Sun..6=Sat. Empty `days` = applies every day.
|
||||
* If `to <= from` the window wraps across midnight (e.g. 22:00 → 07:00).
|
||||
*/
|
||||
export type QuietHours = {
|
||||
enabled: boolean
|
||||
from: string
|
||||
to: string
|
||||
/** Days when the quiet window is active. */
|
||||
days: number[]
|
||||
}
|
||||
|
||||
export type Settings = {
|
||||
globalEnabled: boolean
|
||||
notificationMode: NotificationMode
|
||||
@@ -23,6 +36,7 @@ export type Settings = {
|
||||
theme: Theme
|
||||
language: Language
|
||||
snoozeMinutes: number
|
||||
quietHours: QuietHours
|
||||
}
|
||||
|
||||
export type AppState = {
|
||||
@@ -30,6 +44,18 @@ export type AppState = {
|
||||
settings: Settings
|
||||
challenges: Challenge[]
|
||||
gamesEnabled: Partial<Record<GameId, boolean>>
|
||||
history?: HistoryEntry[]
|
||||
}
|
||||
|
||||
export type HistoryAction = 'done' | 'skip' | 'snooze'
|
||||
|
||||
export type HistoryEntry = {
|
||||
/** ms epoch */
|
||||
ts: number
|
||||
exerciseId: string
|
||||
action: HistoryAction
|
||||
/** When user did less than planned. Only meaningful for `done`. */
|
||||
actualReps?: number
|
||||
}
|
||||
|
||||
export type Tick = {
|
||||
@@ -141,7 +167,36 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
startMinimized: false,
|
||||
theme: 'light',
|
||||
language: 'ru',
|
||||
snoozeMinutes: 5
|
||||
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'>[] = [
|
||||
@@ -151,10 +206,10 @@ export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
|
||||
]
|
||||
|
||||
export type UpdaterStatus =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'idle'; lastCheckedAt?: number }
|
||||
| { kind: 'unsupported'; reason: string }
|
||||
| { kind: 'checking' }
|
||||
| { kind: 'not-available'; currentVersion: string }
|
||||
| { kind: 'not-available'; currentVersion: string; lastCheckedAt?: number }
|
||||
| { kind: 'available'; version: string; releaseDate?: string }
|
||||
| {
|
||||
kind: 'downloading'
|
||||
|
||||
Reference in New Issue
Block a user