3 Commits

Author SHA1 Message Date
AnRil
03ab4eebf5 chore(release): v0.5.3 2026-05-19 17:54:02 +07:00
AnRil
a64f03b3cc docs(v0.5.3): CHANGELOG entry + badge + CLAUDE.md version bump 2026-05-19 17:53:53 +07:00
AnRil
e96ca06587 feat(window): maximize toggle + drag-zone fix + minWidth bump
- Средняя кнопка тайтлбара теперь toggle maximize/restore (была
  hide-to-tray, но иконка Square вводила в заблуждение — выглядит
  как нативная maximize). Double-click по тайтлбару тоже работает.
- Иконка свапается Square ↔ Copy в зависимости от max-state,
  aria-label локализован (titlebar.maximize_aria / restore_aria).
- Новый IPC: toggleMaximizeMain, isMaximizedMain (invoke),
  evtMaximizeChanged (event main → renderer на maximize/unmaximize).
- Фикс drag-зоны: titlebar-nodrag перенесён с обёртки правого
  кластера на сами кнопки. Из-за flex-1 basis-0 пустое место слева
  от кнопок раньше было no-drag — окно нельзя было ухватить рядом.
- minWidth/minHeight окна 900x600 → 1100x700, чтобы Tailwind lg:
  всегда срабатывал (4 hero-stat в один ряд, heatmap без скролла).
- CLAUDE.md: контекст проекта для будущих сессий Claude Code
  (стек, архитектура, команды, релиз, тех. долг, чего не делать).
2026-05-19 17:52:54 +07:00
10 changed files with 287 additions and 15 deletions

View File

@@ -6,6 +6,36 @@
## [Unreleased]
## [0.5.3] — 2026-05-19
Полировка кастомного тайтлбара и размера окна.
### Added
- **Maximize/Restore.** Средняя кнопка тайтлбара (иконка квадрата)
раньше была «спрятать в трей» — выглядела как нативная Windows
maximize и сбивала с толку. Теперь это настоящий toggle на
весь экран: иконка свапается `Square``Copy` в зависимости
от состояния, aria-label локализован.
- **Double-click по тайтлбару** тоже toggleMaximize — стандартный
Windows-жест.
- **CLAUDE.md** в корне — контекст проекта для будущих сессий
Claude Code (стек, архитектура, команды, тех. долг).
### Fixed
- **Drag-зона тайтлбара.** Окно не двигалось, если хватать его
рядом с кнопками свернуть/закрыть. Класс `titlebar-nodrag` стоял
на обёртке кластера с `flex-1 basis-0`, поэтому пустое место
слева от иконок тоже было no-drag. Перенесли `no-drag` на сами
кнопки — теперь тащить можно отовсюду, кроме самих квадратиков.
### Changed
- **Минимальный размер окна** 900×600 → 1100×700. Гарантирует
срабатывание Tailwind `lg:` (4 hero-stat в один ряд, heatmap
и сетка упражнений помещаются без горизонтального скролла).
## [0.5.2] — 2026-05-19
Большая внутренняя итерация: тройной независимый аудит (~220 находок),
@@ -171,7 +201,9 @@
иконки), системный трей, автозапуск с Windows, native-уведомления,
NSIS-инсталлятор, auto-update через electron-updater.
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.1...HEAD
[Unreleased]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/compare/v0.5.3...HEAD
[0.5.3]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.3
[0.5.2]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.2
[0.5.1]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.1
[0.5.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.5.0
[0.4.0]: https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/tag/v0.4.0

168
CLAUDE.md Normal file
View File

@@ -0,0 +1,168 @@
# CLAUDE.md
Контекст проекта для Claude Code. Читается при старте каждой сессии.
## TL;DR
**Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.3**. Один разработчик (AnRil), один remote — self-hosted Gitea.
## Стек
- **Runtime**: Electron 33 (main + preload + renderer)
- **Build**: electron-vite 2 + Vite 5 + electron-builder 25 (NSIS, x64 only)
- **UI**: React 18 + TypeScript 5 + Tailwind 3 + framer-motion + react-router (HashRouter) + zustand 5
- **Auto-update**: electron-updater 6, generic provider, фиксированный канал
- **Тесты**: Vitest 4 (53 теста, все зелёные)
- **Lint/format**: ESLint 8 (flat-ish .eslintrc.cjs) + Prettier 3 + EditorConfig
- **Иконки**: lucide-react (whitelisted lookup через `ICON_CHOICES`)
- **Шрифты**: Plus Jakarta Sans, Bricolage Grotesque, JetBrains Mono (Google Fonts CDN)
## Архитектура (важное)
### Процессы
- **main** (`src/main/`) — Node, scheduler, GSI HTTP-сервер, IPC, окна, tray, updater, persistence
- **preload** (`src/preload/index.ts`) — contextBridge → `window.api`, dev-only методы вырезаны на проде (`import.meta.env.MODE !== 'production'`)
- **renderer** (`src/renderer/src/`) — React, zustand-store, страницы Dashboard/Games/Settings/About, ReminderApp в отдельном окне
### Persistence
- Единственный JSON-файл: `%APPDATA%\Exercise Reminder\app-state.json`
- **Атомарная запись**: tmp + rename + retry на EBUSY/EPERM (антивирус, OneDrive)
- **Не теряет данные**: corrupt JSON → quarantine в `app-state.json.corrupt-<ts>`, не silent wipe
- **Schema migrations**: `__schemaVersion` поле + `MIGRATIONS: Record<number, (s)=>s>` map в `src/main/store.ts`
- **Debounced writes**: pendingWrite с `.unref()`
### IPC
- Типизированные каналы — `src/shared/ipc.ts`
- **Validation layer** — `src/main/validate.ts` (hand-rolled, без zod):
- `intervalMinutes ∈ [1, 1440]`, `reps ∈ [1, 9999]`, `multiplier ∈ [0, 1000]`
- string cap 200 chars, enum-валидация для theme/lang/notify-mode/stat
- HH:MM regex для quietHours, dedup days
- Strip `id` из updateExercise/updateChallenge patch
- **Dev-only**: `dev:simulateMatchEnd` gated на `!app.isPackaged`
### Auto-update (КРИТИЧНО)
- **Фиксированный URL канала**: `…/releases/download/update-channel/latest.yml` — никогда не меняется
- **НЕ** `…/releases/download/v${version}/…` (старая схема ломалась: установленная копия видела только свой релиз)
- Hourly silent auto-check (транзитные сетевые ошибки не показывают красный баннер; только ручной клик показывает ошибку)
- Boot-check: 3 ретрая с backoff 30s/2m/5m
- `lastCheckedAt` → UI «проверено N мин назад»
- Релиз через `scripts/release.ps1` публикует одной командой в:
1. `vX.Y.Z` (постоянный архивный тег)
2. `update-channel` (rolling — клиенты проверяют отсюда)
3. Опциональные `-BridgeTags` для миграции старых пользователей
### Безопасность
- **GSI server** (`src/main/games/gsi-server.ts`): per-install token verify через `timingSafeEqual`, reject Origin/Sec-Fetch-Site (CSRF), 256KB body cap, require `application/json`, generic 400
- **shell.openExternal allowlist**: только `http:`/`https:`/`mailto:` (`src/main/windows.ts`)
- **will-navigate** блокирует non-file:// и non-dev URL
- **Modal focus trap** + focus restore, aria-labelledby
### Quiet hours
- `isQuietAt(time, settings)` в `src/shared/types.ts`
- Wrap-around (22:00 → 07:00) корректно — при wrap-active проверяется день *начала* окна
- Тесты в `src/shared/quiet-hours.test.ts`
### История / стрики
- `src/renderer/src/lib/history.ts` — DST-safe через `shiftDays()` (calendar `setDate`, не ms-арифметика)
- Cap 10k записей, trim oldest 10% на overflow
- HistoryHeatmap: percentile-based bucketing (p25/p50/p85), а не flat ratio (защищает от outlier-дней)
### i18n
- Самописная микро-система: `src/renderer/src/i18n/dict.ts` (плоский словарь ~200 ключей × 2 языка)
- Хук `useT()`, плюрализация CLDR rules для RU (one/few/many)
- Интерполяция через split/join (не regex — защита от regex-инъекций в значениях var)
- Tray menu тоже локализован (`TRAY_STRINGS` в `src/main/tray.ts`)
## Команды
```bash
npm run dev # electron-vite dev
npm run typecheck # tsc по node + web
npm run test:run # vitest один раз
npm run lint # eslint --max-warnings 0
npm run format # prettier --write
npm run dist # сборка + NSIS installer → release/
# Релиз (всё в одном)
npm run release -- -Bump patch
# или -Bump minor / -Bump major / -Version 1.2.3
# опционально: -BridgeTags v0.4.0,v0.4.1
```
## Скрипты релиза
- `scripts/release.ps1` — bump → typecheck → test → build → tag → push → upload в Gitea (vX.Y.Z + update-channel + bridges)
- `scripts/upload-release-assets.ps1` — curl.exe с retry/backoff (15s/45s/2m/5m × 4) на 504/TLS, проверяет уже-залилось через list assets перед ретраем
- **PowerShell 5.1 gotchas**:
- Default reads CP1251 → файлы скриптов **ASCII-only**, без em-dash/кириллицы в коде
- `Set-Content -Encoding utf8` добавляет BOM → ломает PostCSS. Для UTF-8 без BOM использовать `[System.IO.File]::WriteAllText` + `new UTF8Encoding($false)`
- Никогда `-i` флаги (rebase -i, add -i) — нет interactive input
## Gitea remote
- URL: `https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude` (Punycode для `президент.рф`)
- User: `anril`
- Auth: см. `~/.claude/projects/.../memory/gitea_remote.md`
- **Actions выключены** (`has_actions: false`) — релизим через PowerShell, runners не настроены
- `.gitea/workflows/` пустая (раньше там лежали yml → queued runs копились)
## Файлы, которые часто правлю
| Файл | Что |
|---|---|
| `package.json` | version, publish.url, scripts, deps |
| `src/main/store.ts` | persistence, migrations, validation, atomic writes |
| `src/main/ipc.ts` | IPC handlers с валидацией |
| `src/main/scheduler.ts` | таймеры упражнений, powerMonitor |
| `src/main/games/dota2.ts` + `gsi-server.ts` | GSI приём матчей |
| `src/main/updater.ts` | auto-update logic, silent retries |
| `src/shared/types.ts` | shared типы, дефолты, isQuietAt |
| `src/shared/ipc.ts` | IPC channel types |
| `src/renderer/src/i18n/dict.ts` | словари |
| `src/renderer/src/pages/Dashboard.tsx` | главная |
| `src/renderer/src/ReminderApp.tsx` | окно напоминания |
## Тесты (53)
```
src/shared/types.test.ts (4)
src/shared/quiet-hours.test.ts (7)
src/renderer/src/lib/format.test.ts (8)
src/renderer/src/lib/history.test.ts (13)
src/main/games/vdf.test.ts (11)
src/renderer/src/i18n/i18n.test.ts (10)
```
Покрываются: helpers, история/стрики (DST), тихие часы (wrap+filter), VDF-парсер Steam, i18n с плюрализацией, дефолты.
## Технический долг (не для пользователя)
- `sandbox: true` на BrowserWindow — нужен smoke-тест preload в sandbox-режиме
- Self-host Google Fonts (сейчас внешняя CSP-зависимость)
- ReminderApp race: первое напоминание может прийти без озвучки до загрузки settings
- Мажорные апдейты (React 18→19, Electron 33→42, Tailwind 3→4) — каждый ломающий, отдельная итерация
- Code-signing NSIS — ~$300/год, уберёт SmartScreen warning
- Скриншоты в README (есть TODO в самом README)
## Стиль кода
- Prettier: semi:false, singleQuote, trailingComma:none, printWidth:80
- ESLint: eslint:recommended + ts + react + react-hooks (без style rules — это Prettier)
- TypeScript strict, никакого `any` в новом коде
- Комментарии на русском там, где объясняют **почему**, не **что**
- Коммиты на русском, формат `тип(scope): кратко` (feat/fix/docs/refactor/test/chore)
- Co-Authored-By футер в коммитах от Claude
## Управление контекстом
- **Ужимать контекст при достижении 250k токенов** — вызывать `/compact` (или эквивалент) когда суммарный контекст подходит к 250 000 токенов. Не дожидаться authentic переполнения и автоматического сжатия от рантайма — сделать это контролируемо, чтобы важный контекст (открытые правки, недокоммиченные решения, текущая ветка задачи) попал в summary, а не выпал.
## Чего НЕ делать
- Не пушить в `update-channel` руками — только через `release.ps1`
- Не добавлять `.gitea/workflows/*.yml` — has_actions выключен, runs зависнут
- Не использовать regex в i18n-интерполяции — только split/join
- Не silent wipe corrupt JSON — quarantine с timestamp
- Не возвращать ms-арифметику в history.ts — DST сломается
- Не убирать validation layer из IPC — compromised renderer может засунуть NaN/негативы
- Не амендить коммиты без явной просьбы пользователя

View File

@@ -2,8 +2,8 @@
Windows desktop приложение, которое напоминает делать упражнения во время работы за компьютером. Опционально подключается к Dota 2 и после каждого матча превращает статистику (смерти, убийства, ассисты) в количество повторений.
[![release](https://img.shields.io/badge/release-v0.5.2-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
[![tests](https://img.shields.io/badge/tests-51%20passing-green)]()
[![release](https://img.shields.io/badge/release-v0.5.3-orange)](https://xn--90adajar8af4h.xn--p1ai/git/AnRil/laude/releases/latest)
[![tests](https://img.shields.io/badge/tests-53%20passing-green)]()
[![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]()
## Что внутри

View File

@@ -1,6 +1,6 @@
{
"name": "laude",
"version": "0.5.2",
"version": "0.5.3",
"description": "Exercise reminder — Windows desktop app",
"main": "out/main/index.js",
"author": "AnRil",

View File

@@ -176,6 +176,17 @@ export function registerIpc(): void {
BrowserWindow.fromWebContents(event.sender)?.minimize()
})
ipcMain.on(IPC.toggleMaximizeMain, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
if (win.isMaximized()) win.unmaximize()
else win.maximize()
})
ipcMain.handle(IPC.isMaximizedMain, (event) => {
return BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false
})
ipcMain.on(IPC.closeMain, () => {
const main = getMainWindow()
if (!main) return

View File

@@ -1,4 +1,5 @@
import { BrowserWindow, shell, screen, app, nativeImage } from 'electron'
import { IPC } from '../shared/ipc'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
@@ -90,8 +91,13 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
const win = new BrowserWindow({
width: 1100,
height: 720,
minWidth: 900,
minHeight: 600,
// Минимум подобран так, чтобы:
// - срабатывал Tailwind `lg:` (≥1024px) → 4 hero-stat в один ряд, а не 2×2
// - сайдбар (256px) + контент (max-w-5xl, padding lg:px-10) помещались без
// горизонтального скролла heatmap'а и карточек упражнений
// - по вертикали оставался запас на header + stats + heatmap без обрезки
minWidth: 1100,
minHeight: 700,
show: false,
frame: false,
backgroundColor: '#0f1117',
@@ -110,6 +116,16 @@ export function createMainWindow(showImmediately = true): BrowserWindow {
if (showImmediately) win.show()
})
// Сообщаем рендереру об изменении max-состояния, чтобы он мог менять
// иконку (квадрат ↔ «двойной квадрат») в кастомном тайтлбаре.
const emitMaxState = (maximized: boolean): void => {
if (!win.isDestroyed()) {
win.webContents.send(IPC.evtMaximizeChanged, maximized)
}
}
win.on('maximize', () => emitMaxState(true))
win.on('unmaximize', () => emitMaxState(false))
installSafeNavigation(win)
loadRoute(win, 'main')

View File

@@ -54,6 +54,9 @@ const api = {
reminderClose: (): Promise<void> => ipcRenderer.invoke(IPC.reminderClose),
minimizeMain: (): void => ipcRenderer.send(IPC.minimizeMain),
toggleMaximizeMain: (): void => ipcRenderer.send(IPC.toggleMaximizeMain),
isMaximizedMain: (): Promise<boolean> =>
ipcRenderer.invoke(IPC.isMaximizedMain),
closeMain: (): void => ipcRenderer.send(IPC.closeMain),
hideMain: (): void => ipcRenderer.send(IPC.hideMain),
@@ -121,7 +124,9 @@ const api = {
onGamesChanged: (h: Handler<GameStatus[]>): Unsub =>
on(IPC.evtGamesChanged, h),
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
on(IPC.evtUpdaterStatus, h)
on(IPC.evtUpdaterStatus, h),
onMaximizeChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMaximizeChanged, h)
}
contextBridge.exposeInMainWorld('api', api)

View File

@@ -1,4 +1,5 @@
import { Minus, X, Square, Menu } from 'lucide-react'
import { Minus, X, Square, Copy, Menu } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useT } from '../i18n'
type Props = {
@@ -10,8 +11,29 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
const { t } = useT()
const effectiveTitle = title ?? t('titlebar.app_title')
// Локально отслеживаем maximize-state, чтобы свапать иконку (квадрат ↔
// «двойной квадрат», как в нативной винде). Стартовое значение спрашиваем
// у main; дальше подписываемся на evtMaximizeChanged.
const [maximized, setMaximized] = useState(false)
useEffect(() => {
void window.api.isMaximizedMain().then(setMaximized)
const unsub = window.api.onMaximizeChanged((v) => setMaximized(v))
return unsub
}, [])
// Double-click по тайтлбару — стандартный Windows-жест для toggle maximize.
// Игнорируем клики по элементам с no-drag (кнопки/меню) — у них своя логика.
function onDoubleClick(e: React.MouseEvent<HTMLDivElement>): void {
const target = e.target as HTMLElement
if (target.closest('.titlebar-nodrag')) return
window.api.toggleMaximizeMain()
}
return (
<div className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b">
<div
onDoubleClick={onDoubleClick}
className="titlebar-drag relative h-10 px-2 sm:px-3 flex items-center justify-between vibrancy hairline-b"
>
<div className="flex items-center gap-1 min-w-0 flex-1 basis-0">
{onMenuClick && (
<button
@@ -28,7 +50,10 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
{effectiveTitle}
</div>
<div className="titlebar-nodrag flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
{/* no-drag навешен на сами кнопки, не на обёртку: иначе из-за
flex-1 basis-0 весь кластер (включая пустое место слева от кнопок)
становится no-drag, и окно нельзя ухватить рядом с кнопками. */}
<div className="flex items-center justify-end gap-0.5 min-w-0 flex-1 basis-0">
<WinBtn
onClick={() => window.api.minimizeMain()}
label={t('titlebar.minimize_aria')}
@@ -36,10 +61,18 @@ export function Titlebar({ title, onMenuClick }: Props): JSX.Element {
<Minus size={13} strokeWidth={2} />
</WinBtn>
<WinBtn
onClick={() => window.api.hideMain()}
label={t('titlebar.tray_aria')}
onClick={() => window.api.toggleMaximizeMain()}
label={
maximized
? t('titlebar.restore_aria')
: t('titlebar.maximize_aria')
}
>
<Square size={11} strokeWidth={2} />
{maximized ? (
<Copy size={11} strokeWidth={2} />
) : (
<Square size={11} strokeWidth={2} />
)}
</WinBtn>
<WinBtn
onClick={() => window.api.closeMain()}
@@ -69,7 +102,7 @@ function WinBtn({
onClick={onClick}
aria-label={label}
className={[
'w-9 h-7 grid place-items-center rounded-md transition-colors text-text/55',
'titlebar-nodrag w-9 h-7 grid place-items-center rounded-md transition-colors text-text/55',
danger
? 'hover:bg-destructive hover:text-white'
: 'hover:bg-text/[0.08] hover:text-text'

View File

@@ -21,6 +21,8 @@ export const ru: Dict = {
'sidebar.status_tracking': 'Активность отслеживается',
'titlebar.menu_aria': 'Меню',
'titlebar.minimize_aria': 'Свернуть',
'titlebar.maximize_aria': 'Развернуть',
'titlebar.restore_aria': 'Восстановить размер',
'titlebar.tray_aria': 'В трей',
'titlebar.close_aria': 'Закрыть',
'titlebar.app_title': 'Exercise Reminder',
@@ -265,6 +267,8 @@ export const en: Dict = {
'sidebar.status_tracking': 'Activity tracking is on',
'titlebar.menu_aria': 'Menu',
'titlebar.minimize_aria': 'Minimize',
'titlebar.maximize_aria': 'Maximize',
'titlebar.restore_aria': 'Restore size',
'titlebar.tray_aria': 'To tray',
'titlebar.close_aria': 'Close',
'titlebar.app_title': 'Exercise Reminder',

View File

@@ -16,6 +16,8 @@ export const IPC = {
resumeAll: 'app:resumeAll',
quit: 'app:quit',
minimizeMain: 'window:minimize',
toggleMaximizeMain: 'window:toggleMaximize',
isMaximizedMain: 'window:isMaximized',
closeMain: 'window:close',
hideMain: 'window:hide',
@@ -54,5 +56,6 @@ export const IPC = {
evtThemeChanged: 'evt:themeChanged',
evtAccentChanged: 'evt:accentChanged',
evtGamesChanged: 'evt:gamesChanged',
evtUpdaterStatus: 'evt:updaterStatus'
evtUpdaterStatus: 'evt:updaterStatus',
evtMaximizeChanged: 'evt:maximizeChanged'
} as const