diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ebad60c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,168 @@ +# CLAUDE.md + +Контекст проекта для Claude Code. Читается при старте каждой сессии. + +## TL;DR + +**Laude / Exercise Reminder** — Windows desktop приложение на Electron 33, которое напоминает делать упражнения и опционально парсит статистику матчей Dota 2 (через GSI) в количество повторений. Текущая версия — **0.5.2**. Один разработчик (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-`, не silent wipe +- **Schema migrations**: `__schemaVersion` поле + `MIGRATIONS: Records>` 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/негативы +- Не амендить коммиты без явной просьбы пользователя diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 1f467fa..d6270dc 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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 diff --git a/src/main/windows.ts b/src/main/windows.ts index 4f7bad0..69e7775 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -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') diff --git a/src/preload/index.ts b/src/preload/index.ts index bdc6685..e0c6140 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -54,6 +54,9 @@ const api = { reminderClose: (): Promise => ipcRenderer.invoke(IPC.reminderClose), minimizeMain: (): void => ipcRenderer.send(IPC.minimizeMain), + toggleMaximizeMain: (): void => ipcRenderer.send(IPC.toggleMaximizeMain), + isMaximizedMain: (): Promise => + 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): Unsub => on(IPC.evtGamesChanged, h), onUpdaterStatus: (h: Handler): Unsub => - on(IPC.evtUpdaterStatus, h) + on(IPC.evtUpdaterStatus, h), + onMaximizeChanged: (h: Handler): Unsub => + on(IPC.evtMaximizeChanged, h) } contextBridge.exposeInMainWorld('api', api) diff --git a/src/renderer/src/components/Titlebar.tsx b/src/renderer/src/components/Titlebar.tsx index d477c11..7da117c 100644 --- a/src/renderer/src/components/Titlebar.tsx +++ b/src/renderer/src/components/Titlebar.tsx @@ -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): void { + const target = e.target as HTMLElement + if (target.closest('.titlebar-nodrag')) return + window.api.toggleMaximizeMain() + } + return ( -
+
{onMenuClick && (