From 349ce51c67b423c2f45f0d425d511408dbfdd3fa Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 8 Jun 2026 13:19:20 +0700 Subject: [PATCH] feat(settings): add status-first control center --- CHANGELOG.md | 28 +- README.md | 3 +- package.json | 1 + src/renderer/src/App.tsx | 10 +- src/renderer/src/components/PageScaffold.tsx | 11 +- src/renderer/src/components/Sidebar.tsx | 41 +- src/renderer/src/i18n/dict.ts | 50 +- src/renderer/src/lib/dev-api.ts | 597 +++++++++++++++++++ src/renderer/src/main.tsx | 29 +- src/renderer/src/pages/Dashboard.tsx | 4 +- src/renderer/src/pages/Settings.tsx | 180 +++++- src/shared/release-notes.ts | 54 ++ vite.renderer.config.mjs | 19 + 13 files changed, 961 insertions(+), 66 deletions(-) create mode 100644 src/renderer/src/lib/dev-api.ts create mode 100644 vite.renderer.config.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2cdd3..9075d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ ## [Unreleased] +## [0.6.6] — 2026-06-08 + +### Added + +- Settings получили верхнюю панель состояния: сразу видно, работают ли напоминания, + включены ли тихие часы, авто-пауза встреч и запуск вместе с Windows. +- Добавлен renderer-стенд `npm run dev:renderer` с демо-данными для безопасной + визуальной проверки UI без доступа к реальным пользовательским настройкам. + +### Changed + +- Settings перегруппированы: язык и тема теперь находятся в одном блоке “Интерфейс”, + а главный переключатель напоминаний вынесен в раздел “Напоминания”. +- Сводные карточки на вторичных страницах больше не обрезают длинные значения и + используют более спокойную сетку для десктопных ширин. +- Sidebar теперь показывает “Напоминания на паузе”, если глобальный режим остановлен. + +### Fixed + +- На главном экране дата больше не получает искусственную капитализацию. +- Цели и ближайшие действия показывают единицы измерения: например “осталось 30 раз”. +- Русский статус трекинга матчей больше не показывает английское `Setup`. + ## [0.6.5] — 2026-06-07 ### Added @@ -387,7 +410,7 @@ clearHistory/import`, Dashboard на него подписан. - **Modal focus trap + focus restore + aria-labelledby.** Tab/Shift-Tab больше не вываливаются на нижний слой; на закрытии фокус возвращается на триггер. -- **Sidebar mobile drawer:** Esc закрывает, focus trap внутри, focus +- **Sidebar compact drawer:** Esc закрывает, focus trap внутри, focus restore на гамбургер, `role="dialog"` + `aria-modal`. - **Tray menu i18n** — пункты меню следуют `settings.language`. - **Bilingual heatmap.** Title, легенда, weekday-лейблы и tooltip @@ -527,7 +550,8 @@ days=[Mon..Fri]` теперь правильно проверяется день иконки), системный трей, автозапуск с Windows, native-уведомления, NSIS-инсталлятор, auto-update через electron-updater. -[Unreleased]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.5...HEAD +[Unreleased]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.6...HEAD +[0.6.6]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.5...v0.6.6 [0.6.5]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.4...v0.6.5 [0.6.4]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.3...v0.6.4 [0.6.3]: https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/compare/v0.6.2...v0.6.3 diff --git a/README.md b/README.md index 5caf9f2..b8ad1e3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Windows desktop приложение, которое помогает делать короткие перерывы без потери фокуса: держит план дня, напоминает размяться, ведёт недельные челленджи и превращает Dota 2 статистику после матча в игровые долги. -[![release](https://img.shields.io/badge/release-v0.6.5-orange)](https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/latest) +[![release](https://img.shields.io/badge/release-v0.6.6-orange)](https://git.xn--90adajar8af4h.xn--p1ai/AnRil/laude/releases/latest) [![tests](https://img.shields.io/badge/tests-245%20passing-green)]() [![platform](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)]() @@ -49,6 +49,7 @@ npm run typecheck # tsc по main + renderer npm run verify # typecheck + tests + lint + build + audit summary npm run test # vitest в watch-режиме npm run test:run # vitest один раз (для CI) +npm run dev:renderer # renderer-стенд с демо-данными для UI-проверки npm run build # сборка без NSIS npm run dist # сборка + NSIS-инсталлятор → release/ npm run release -- -Bump patch # bump версии + tag + push + upload в Gitea diff --git a/package.json b/package.json index ffea68e..e346c9a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "private": true, "scripts": { "dev": "electron-vite dev", + "dev:renderer": "vite --config vite.renderer.config.mjs", "build": "electron-vite build", "preview": "electron-vite preview", "typecheck:node": "tsc --noEmit -p tsconfig.node.json", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 728cd0c..b3ae5c6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -22,7 +22,7 @@ let backendSubscribed = false export default function App(): JSX.Element { const hydrated = useAppStore((s) => s.hydrated) const settings = useAppStore((s) => s.state?.settings) - const [mobileNavOpen, setMobileNavOpen] = useState(false) + const [compactNavOpen, setCompactNavOpen] = useState(false) const [whatsNew, setWhatsNew] = useState<{ open: boolean versions: string[] @@ -90,16 +90,16 @@ export default function App(): JSX.Element {
- setMobileNavOpen(true)} /> + setCompactNavOpen(true)} />
setMobileNavOpen(false)} + compactOpen={compactNavOpen} + onCompactClose={() => setCompactNavOpen(false)} />
{hydrated ? ( - setMobileNavOpen(false)} /> + setCompactNavOpen(false)} /> ) : ( // Skeleton на время гидрации — settings (и язык) ещё не diff --git a/src/renderer/src/components/PageScaffold.tsx b/src/renderer/src/components/PageScaffold.tsx index 24f8908..c81ec85 100644 --- a/src/renderer/src/components/PageScaffold.tsx +++ b/src/renderer/src/components/PageScaffold.tsx @@ -40,9 +40,10 @@ export function InsightGrid({ }): JSX.Element { return (
{children}
@@ -85,10 +86,10 @@ export function InsightCard({ {icon}
-
+
{label}
-
+
{value}
{hint && ( diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 2811e5f..9f075ae 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { X } from 'lucide-react' import { useT } from '../i18n' +import { useAppStore } from '../store/appStore' type Item = { to: string @@ -50,28 +51,28 @@ const items: Item[] = [ ] type Props = { - mobileOpen?: boolean - onMobileClose?: () => void + compactOpen?: boolean + onCompactClose?: () => void } export function Sidebar({ - mobileOpen = false, - onMobileClose + compactOpen = false, + onCompactClose }: Props): JSX.Element { const { t } = useT() const drawerRef = useRef(null) const lastFocusedRef = useRef(null) - // Esc closes + focus trap while the mobile drawer is open. Mirrors the - // pattern used in Modal.tsx. + // Esc closes + focus trap while the compact drawer is open. Mirrors the + // pattern used in Modal.tsx for keyboard users. useEffect(() => { - if (!mobileOpen) return undefined + if (!compactOpen) return undefined lastFocusedRef.current = document.activeElement as HTMLElement | null const onKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault() - onMobileClose?.() + onCompactClose?.() return } if (e.key !== 'Tab') return @@ -104,7 +105,7 @@ export function Sidebar({ const target = lastFocusedRef.current if (target && document.body.contains(target)) target.focus() } - }, [mobileOpen, onMobileClose]) + }, [compactOpen, onCompactClose]) return ( <> @@ -113,7 +114,7 @@ export function Sidebar({ - {mobileOpen && ( + {compactOpen && ( - + )} @@ -158,6 +159,7 @@ export function Sidebar({ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element { const { t } = useT() + const running = useAppStore((s) => s.state?.settings.globalEnabled ?? true) return ( <>
@@ -214,10 +216,17 @@ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
- - + {running && ( + + )} + - {t('sidebar.status_tracking')} + {running ? t('sidebar.status_tracking') : t('sidebar.status_paused')}
diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index 6904c32..aee97de 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -20,6 +20,7 @@ export const ru: Dict = { 'nav.settings': 'Настройки', 'sidebar.slogan': 'Лёгкий перерыв без потери фокуса', 'sidebar.status_tracking': 'Активность отслеживается', + 'sidebar.status_paused': 'Напоминания на паузе', 'titlebar.menu_aria': 'Меню', 'titlebar.minimize_aria': 'Свернуть', 'titlebar.maximize_aria': 'Развернуть', @@ -98,7 +99,7 @@ export const ru: Dict = { 'dashboard.stat.tracking': 'Трекинг матчей', 'dashboard.stat.tracking.on': 'On', 'dashboard.stat.tracking.off': 'Off', - 'dashboard.stat.tracking.pending': 'Setup', + 'dashboard.stat.tracking.pending': 'Настройка', 'dashboard.stat.tracking.subtitle_on': 'в реальном времени', 'dashboard.stat.tracking.subtitle_off': 'выключен', 'dashboard.stat.tracking.subtitle_pending': @@ -110,7 +111,7 @@ export const ru: Dict = { 'Запущен Zoom / Teams / Webex / Slack-huddle. Напоминания возобновятся, когда встреча закончится.', 'dashboard.plan.title': 'Ближайший шаг', 'dashboard.plan.subtitle': 'Что сделать сейчас, дневные цели и питание', - 'dashboard.plan.due_count': '{n} ждёт', + 'dashboard.plan.due_count': '{n} к выполнению', 'dashboard.plan.all_caught_up': 'всё спокойно', 'dashboard.plan.next_action': 'Следующее действие', 'dashboard.plan.kind.exercise': 'упражнение', @@ -125,7 +126,7 @@ export const ru: Dict = { 'dashboard.plan.clear.hint': 'Можно отдохнуть или добавить новое действие', 'dashboard.plan.goals': 'Дневные цели', 'dashboard.plan.goals.progress': '{done}/{goal}', - 'dashboard.plan.goals.remaining': 'осталось {n}', + 'dashboard.plan.goals.remaining': 'осталось {n} раз', 'dashboard.plan.goals.hint': 'прогресс по упражнениям с дневной целью', 'dashboard.plan.goals.empty': 'Добавь дневную цель в упражнении, чтобы видеть прогресс', @@ -143,6 +144,7 @@ export const ru: Dict = { 'dashboard.plan.recovery.steady.none': 'держим спокойный темп', 'dashboard.plan.up_next': 'Дальше', 'dashboard.plan.item.remaining': 'осталось {n}', + 'dashboard.plan.item.remaining_reps': 'осталось {n} раз', 'dashboard.plan.item.reps': '{n} раз', 'dashboard.empty.title': 'Программа пуста', 'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать', @@ -314,11 +316,29 @@ export const ru: Dict = { 'settings.insight.theme.hint': 'Визуальный режим интерфейса', 'settings.insight.language': 'Язык', 'settings.insight.language.hint': 'Применяется без перезапуска', + 'settings.status.kicker': 'Состояние', + 'settings.status.title.on': 'Напоминания работают', + 'settings.status.title.off': 'Напоминания остановлены', + 'settings.status.hint.paused': + 'Перерывы не будут появляться, пока ты снова не запустишь напоминания.', + 'settings.status.hint.quiet': + 'Напоминания работают, но тихие часы {from}-{to} могут временно их скрывать.', + 'settings.status.hint.autostart': + 'После перезагрузки приложение не запустится само.', + 'settings.status.hint.ready': + 'Приложение запустится с Windows и будет мягко вести расписание перерывов.', + 'settings.status.reminders': 'Напоминания', + 'settings.status.quiet': 'Тихие часы', + 'settings.status.meetings': 'Встречи', + 'settings.status.autostart': 'Windows', + 'settings.status.on': 'Вкл', + 'settings.status.off': 'Выкл', 'settings.section.reminders': 'Напоминания', 'settings.section.quiet': 'Тихие часы', 'settings.section.window': 'Окно и трей', 'settings.section.appearance': 'Внешний вид', 'settings.section.language': 'Язык', + 'settings.section.interface': 'Интерфейс', 'settings.section.updates': 'Обновления', 'settings.section.data': 'Данные', 'settings.section.diagnostics': 'Диагностика', @@ -364,6 +384,8 @@ export const ru: Dict = { 'settings.notification_mode.modal': 'Окно поверх всех', 'settings.notification_mode.toast': 'Системное уведомление', 'settings.notification_mode.both': 'Окно и уведомление', + 'settings.global.label': 'Напоминания включены', + 'settings.global.hint': 'Главный режим работы приложения', 'settings.sound.label': 'Звук уведомления', 'settings.sound.hint': 'Короткий сигнал при срабатывании', 'settings.voice.label': 'Голосовая подсказка', @@ -532,6 +554,7 @@ export const en: Dict = { 'nav.settings': 'Settings', 'sidebar.slogan': 'A small break without losing focus', 'sidebar.status_tracking': 'Activity tracking is on', + 'sidebar.status_paused': 'Reminders paused', 'titlebar.menu_aria': 'Menu', 'titlebar.minimize_aria': 'Minimize', 'titlebar.maximize_aria': 'Maximize', @@ -654,6 +677,7 @@ export const en: Dict = { 'dashboard.plan.recovery.steady.none': 'keep a calm pace', 'dashboard.plan.up_next': 'Up next', 'dashboard.plan.item.remaining': '{n} left', + 'dashboard.plan.item.remaining_reps': '{n} reps left', 'dashboard.plan.item.reps': '{n} reps', 'dashboard.empty.title': 'Program is empty', 'dashboard.empty.hint': 'Add your first exercise to start', @@ -825,11 +849,29 @@ export const en: Dict = { 'settings.insight.theme.hint': 'Visual interface mode', 'settings.insight.language': 'Language', 'settings.insight.language.hint': 'Applied without restart', + 'settings.status.kicker': 'Status', + 'settings.status.title.on': 'Reminders are running', + 'settings.status.title.off': 'Reminders are paused', + 'settings.status.hint.paused': + 'Breaks will not appear until reminders are started again.', + 'settings.status.hint.quiet': + 'Reminders are running, but quiet hours {from}-{to} can hide them temporarily.', + 'settings.status.hint.autostart': + 'The app will not start automatically after reboot.', + 'settings.status.hint.ready': + 'The app will start with Windows and keep the break schedule moving.', + 'settings.status.reminders': 'Reminders', + 'settings.status.quiet': 'Quiet hours', + 'settings.status.meetings': 'Meetings', + 'settings.status.autostart': 'Windows', + 'settings.status.on': 'On', + 'settings.status.off': 'Off', 'settings.section.reminders': 'Reminders', 'settings.section.quiet': 'Quiet hours', 'settings.section.window': 'Window & tray', 'settings.section.appearance': 'Appearance', 'settings.section.language': 'Language', + 'settings.section.interface': 'Interface', 'settings.section.updates': 'Updates', 'settings.section.data': 'Data', 'settings.section.diagnostics': 'Diagnostics', @@ -875,6 +917,8 @@ export const en: Dict = { 'settings.notification_mode.modal': 'Window on top', 'settings.notification_mode.toast': 'System notification', 'settings.notification_mode.both': 'Window and notification', + 'settings.global.label': 'Reminders enabled', + 'settings.global.hint': 'Main operating mode for the app', 'settings.sound.label': 'Notification sound', 'settings.sound.hint': 'Short beep on trigger', 'settings.voice.label': 'Voice prompt', diff --git a/src/renderer/src/lib/dev-api.ts b/src/renderer/src/lib/dev-api.ts new file mode 100644 index 0000000..3900659 --- /dev/null +++ b/src/renderer/src/lib/dev-api.ts @@ -0,0 +1,597 @@ +import { + DEFAULT_SETTINGS, + nextMealOccurrence, + type AppState, + type Challenge, + type DiagnosticsInfo, + type Exercise, + type GameId, + type GameStatus, + type HistoryEntry, + type Meal, + type RendererErrorReport, + type Settings, + type Tick, + type UpdaterStatus +} from '@shared/types' + +type Api = Window['api'] +type Handler = (payload: T) => void + +const now = Date.now() + +let state: AppState = { + exercises: [ + { + id: 'dev-ex-squats', + name: 'Приседания', + reps: 10, + icon: 'Activity', + intervalMinutes: 30, + enabled: true, + nextFireAt: now - 90_000, + lastDoneAt: now - 2 * 60 * 60 * 1000, + category: 'exercise', + dailyGoal: 40, + adaptive: true + }, + { + id: 'dev-ex-eyes', + name: 'Отдых глазам 20-20-20', + reps: 1, + icon: 'Eye', + intervalMinutes: 20, + enabled: true, + nextFireAt: now + 9 * 60_000, + category: 'eyes' + }, + { + id: 'dev-ex-water', + name: 'Стакан воды', + reps: 1, + icon: 'GlassWater', + intervalMinutes: 60, + enabled: true, + nextFireAt: now + 26 * 60_000, + category: 'hydration', + dailyGoal: 6 + }, + { + id: 'dev-ex-posture', + name: 'Проверь осанку', + reps: 1, + icon: 'PersonStanding', + intervalMinutes: 25, + enabled: false, + nextFireAt: now + 25 * 60_000, + category: 'posture' + } + ], + meals: [ + { + id: 'dev-meal-breakfast', + name: 'Завтрак', + time: '08:00', + icon: 'Coffee', + enabled: true, + days: [], + nextFireAt: nextMealOccurrence('08:00', [], now), + lastDoneAt: now - 5 * 60 * 60 * 1000 + }, + { + id: 'dev-meal-lunch', + name: 'Обед', + time: '13:00', + icon: 'UtensilsCrossed', + enabled: true, + days: [], + nextFireAt: nextMealOccurrence('13:00', [], now) + }, + { + id: 'dev-meal-dinner', + name: 'Ужин', + time: '19:00', + icon: 'Soup', + enabled: false, + days: [], + nextFireAt: nextMealOccurrence('19:00', [], now) + } + ], + settings: { + ...DEFAULT_SETTINGS, + lastSeenVersion: '0.6.5' + }, + challenges: [ + { + id: 'dev-ch-deaths', + name: 'За смерти в Dota', + gameId: 'dota2', + stat: 'deaths', + multiplier: 3, + exerciseName: 'Приседания', + icon: 'Activity', + enabled: true + }, + { + id: 'dev-ch-kills', + name: 'За убийства', + gameId: 'dota2', + stat: 'kills', + multiplier: 1, + exerciseName: 'Отжимания', + icon: 'Dumbbell', + enabled: false + } + ], + gamesEnabled: { dota2: true } +} + +let history: HistoryEntry[] = [ + { + ts: now - 2 * 60 * 60 * 1000, + exerciseId: 'dev-ex-squats', + action: 'done', + reps: 10, + name: 'Приседания', + source: 'reminder' + }, + { + ts: now - 5 * 60 * 60 * 1000, + exerciseId: 'meal:dev-meal-breakfast', + action: 'done', + reps: 1, + name: 'Завтрак', + source: 'meal' + }, + { + ts: now - 26 * 60 * 60 * 1000, + exerciseId: 'dev-ex-eyes', + action: 'done', + reps: 1, + name: 'Отдых глазам 20-20-20', + source: 'reminder' + }, + { + ts: now - 48 * 60 * 60 * 1000, + exerciseId: 'dev-ex-squats', + action: 'done', + reps: 10, + name: 'Приседания', + source: 'reminder' + } +] + +let games: GameStatus[] = [ + { + id: 'dota2', + name: 'Dota 2', + installed: true, + installPath: + 'C:\\Program Files (x86)\\Steam\\steamapps\\common\\dota 2 beta', + integrationActive: false, + launchOption: '-gamestateintegration', + launchOptionStatus: 'queued', + steamRunning: true, + enabled: true + } +] + +let updaterStatus: UpdaterStatus = { + kind: 'not-available', + currentVersion: '0.6.5', + lastCheckedAt: now - 12 * 60_000 +} + +const stateHandlers = new Set>() +const tickHandlers = new Set>() +const historyHandlers = new Set>() +const gamesHandlers = new Set>() +const updaterHandlers = new Set>() +const themeHandlers = new Set>() +const emptyUnsub = (): void => undefined +let tickTimer: number | undefined + +function cloneState(): AppState { + return structuredClone(state) +} + +function emitState(): void { + const snapshot = cloneState() + stateHandlers.forEach((handler) => handler(snapshot)) +} + +function emitHistory(): void { + historyHandlers.forEach((handler) => handler()) +} + +function emitGames(): void { + const snapshot = structuredClone(games) + gamesHandlers.forEach((handler) => handler(snapshot)) +} + +function emitUpdater(): void { + updaterHandlers.forEach((handler) => handler(updaterStatus)) +} + +function pushHistory(entry: HistoryEntry): void { + history = [entry, ...history] + emitHistory() +} + +function findExercise(id: string): Exercise { + const exercise = state.exercises.find((item) => item.id === id) + if (!exercise) throw new Error(`Unknown exercise ${id}`) + return exercise +} + +function findMeal(id: string): Meal { + const meal = state.meals.find((item) => item.id === id) + if (!meal) throw new Error(`Unknown meal ${id}`) + return meal +} + +function nextId(prefix: string): string { + return `${prefix}-${Math.random().toString(36).slice(2, 9)}` +} + +function subscribe(set: Set>, handler: Handler): () => void { + set.add(handler) + return () => set.delete(handler) +} + +function buildTicks(): Tick[] { + return state.exercises.map((exercise) => ({ + exerciseId: exercise.id, + enabled: exercise.enabled, + msUntilFire: exercise.nextFireAt - Date.now() + })) +} + +if (import.meta.hot) { + import.meta.hot.dispose(() => { + if (tickTimer !== undefined) window.clearInterval(tickTimer) + }) +} + +export function installDevApi(): void { + if (window.api || !import.meta.env.DEV) return + + const api: Api = { + getState: async () => cloneState(), + addExercise: async (input) => { + const exercise: Exercise = { + ...input, + id: nextId('dev-ex'), + nextFireAt: Date.now() + input.intervalMinutes * 60_000 + } + state = { ...state, exercises: [...state.exercises, exercise] } + emitState() + return structuredClone(exercise) + }, + updateExercise: async (id, patch) => { + let updated = findExercise(id) + state = { + ...state, + exercises: state.exercises.map((exercise) => { + if (exercise.id !== id) return exercise + updated = { ...exercise, ...patch, id } + return updated + }) + } + emitState() + return structuredClone(updated) + }, + deleteExercise: async (id) => { + const before = state.exercises.length + state = { + ...state, + exercises: state.exercises.filter((exercise) => exercise.id !== id) + } + emitState() + return state.exercises.length !== before + }, + toggleExercise: async (id, enabled) => { + return api.updateExercise(id, { enabled }) + }, + markDone: async (id, actualReps) => { + const exercise = findExercise(id) + const updated = await api.updateExercise(id, { + lastDoneAt: Date.now(), + nextFireAt: Date.now() + exercise.intervalMinutes * 60_000 + }) + pushHistory({ + ts: Date.now(), + exerciseId: id, + action: 'done', + actualReps, + reps: exercise.reps, + name: exercise.name, + source: 'reminder' + }) + return updated + }, + snooze: async (id, minutes) => { + return api.updateExercise(id, { + nextFireAt: Date.now() + minutes * 60_000 + }) + }, + skip: async (id) => { + const exercise = findExercise(id) + const updated = await api.updateExercise(id, { + nextFireAt: Date.now() + exercise.intervalMinutes * 60_000 + }) + pushHistory({ + ts: Date.now(), + exerciseId: id, + action: 'skip', + reps: exercise.reps, + name: exercise.name, + source: 'reminder' + }) + return updated + }, + addMeal: async (input) => { + const meal: Meal = { + ...input, + id: nextId('dev-meal'), + nextFireAt: nextMealOccurrence(input.time, input.days, Date.now()) + } + state = { ...state, meals: [...state.meals, meal] } + emitState() + return structuredClone(meal) + }, + updateMeal: async (id, patch) => { + let updated = findMeal(id) + state = { + ...state, + meals: state.meals.map((meal) => { + if (meal.id !== id) return meal + updated = { ...meal, ...patch, id } + if ( + (patch.time !== undefined || + patch.days !== undefined || + patch.enabled !== undefined) && + patch.nextFireAt === undefined + ) { + updated.nextFireAt = nextMealOccurrence( + updated.time, + updated.days, + Date.now() + ) + } + return updated + }) + } + emitState() + return structuredClone(updated) + }, + deleteMeal: async (id) => { + const before = state.meals.length + state = { ...state, meals: state.meals.filter((meal) => meal.id !== id) } + emitState() + return state.meals.length !== before + }, + toggleMeal: async (id, enabled) => api.updateMeal(id, { enabled }), + markMealDone: async (id) => { + const meal = findMeal(id) + const updated = await api.updateMeal(id, { + lastDoneAt: Date.now(), + nextFireAt: nextMealOccurrence(meal.time, meal.days, Date.now()) + }) + pushHistory({ + ts: Date.now(), + exerciseId: `meal:${id}`, + action: 'done', + reps: 1, + name: meal.name, + source: 'meal' + }) + return updated + }, + updateSettings: async (patch: Partial) => { + state = { ...state, settings: { ...state.settings, ...patch } } + if (patch.theme === 'light' || patch.theme === 'dark') { + themeHandlers.forEach((handler) => + handler(patch.theme as 'light' | 'dark') + ) + } + emitState() + return structuredClone(state.settings) + }, + getAccentColor: async () => '#ff6b35', + getOsTheme: async () => 'light', + getAppVersion: async () => '0.6.5', + getMeetingActive: async () => false, + getDiagnostics: async () => diagnostics(), + openLogsFolder: async () => ({ ok: true }), + copyDiagnostics: async () => diagnostics(), + reportRendererError: async (report: RendererErrorReport) => { + console.warn('[dev-api] renderer error', report) + return true + }, + pauseAll: async () => { + await api.updateSettings({ globalEnabled: false }) + }, + resumeAll: async () => { + await api.updateSettings({ globalEnabled: true }) + }, + quit: async () => undefined, + reminderClose: async () => undefined, + minimizeMain: () => undefined, + toggleMaximizeMain: () => undefined, + isMaximizedMain: async () => false, + closeMain: () => undefined, + hideMain: () => undefined, + listGames: async () => structuredClone(games), + installGame: async (id: GameId) => { + games = games.map((game) => + game.id === id + ? { + ...game, + enabled: true, + integrationActive: true, + launchOptionStatus: 'applied' + } + : game + ) + emitGames() + return structuredClone(games.find((game) => game.id === id)!) + }, + uninstallGame: async (id: GameId) => { + games = games.map((game) => + game.id === id + ? { ...game, enabled: false, integrationActive: false } + : game + ) + emitGames() + return structuredClone(games.find((game) => game.id === id)!) + }, + toggleGame: async (id, enabled) => { + games = games.map((game) => + game.id === id ? { ...game, enabled } : game + ) + state = { + ...state, + gamesEnabled: { ...state.gamesEnabled, [id]: enabled } + } + emitGames() + emitState() + }, + openGameLaunchOptions: async () => undefined, + addChallenge: async (input) => { + const challenge: Challenge = { ...input, id: nextId('dev-ch') } + state = { ...state, challenges: [...state.challenges, challenge] } + emitState() + return structuredClone(challenge) + }, + updateChallenge: async (id, patch) => { + let updated = state.challenges.find((challenge) => challenge.id === id) + if (!updated) throw new Error(`Unknown challenge ${id}`) + state = { + ...state, + challenges: state.challenges.map((challenge) => { + if (challenge.id !== id) return challenge + updated = { ...challenge, ...patch, id } + return updated + }) + } + emitState() + return structuredClone(updated) + }, + deleteChallenge: async (id) => { + const before = state.challenges.length + state = { + ...state, + challenges: state.challenges.filter((challenge) => challenge.id !== id) + } + emitState() + return state.challenges.length !== before + }, + toggleChallenge: async (id, enabled) => { + return api.updateChallenge(id, { enabled }) + }, + markChallengeDone: async (id, reps) => { + const challenge = state.challenges.find((item) => item.id === id) + pushHistory({ + ts: Date.now(), + exerciseId: `challenge:${id}`, + action: 'done', + actualReps: reps, + reps, + name: challenge?.exerciseName ?? challenge?.name, + source: 'match' + }) + return true + }, + closeMatchSummary: async () => undefined, + simulateMatchEnd: async () => undefined, + updaterStatus: async () => updaterStatus, + updaterCheck: async () => { + updaterStatus = { + kind: 'not-available', + currentVersion: '0.6.5', + lastCheckedAt: Date.now() + } + emitUpdater() + return updaterStatus + }, + updaterDownload: () => undefined, + updaterInstall: () => undefined, + getHistory: async (sinceMs) => + structuredClone( + sinceMs === undefined + ? history + : history.filter((entry) => entry.ts >= sinceMs) + ), + clearHistory: async (beforeTs) => { + const before = history.length + history = + beforeTs === undefined + ? history + : history.filter((entry) => entry.ts >= beforeTs) + emitHistory() + return before - history.length + }, + exportState: async () => ({ + ok: true, + canceled: false, + path: 'C:\\Users\\Demo\\Desktop\\razomnis-backup.json' + }), + importState: async () => ({ ok: true, canceled: false }), + onTick: (handler) => subscribe(tickHandlers, handler), + onFire: () => emptyUnsub, + onFireMeal: () => emptyUnsub, + onMatchEnd: () => emptyUnsub, + onStateChanged: (handler) => subscribe(stateHandlers, handler), + onThemeChanged: (handler) => subscribe(themeHandlers, handler), + onAccentChanged: () => emptyUnsub, + onGamesChanged: (handler) => subscribe(gamesHandlers, handler), + onUpdaterStatus: (handler) => subscribe(updaterHandlers, handler), + onMaximizeChanged: () => emptyUnsub, + onMeetingChanged: () => emptyUnsub, + onHistoryChanged: (handler) => subscribe(historyHandlers, handler) + } + + window.api = api + tickTimer = window.setInterval(() => { + const ticks = buildTicks() + tickHandlers.forEach((handler) => handler(ticks)) + }, 1000) +} + +function diagnostics(): DiagnosticsInfo { + return { + generatedAt: Date.now(), + app: { + version: '0.6.5', + isPackaged: false, + platform: 'win32', + arch: 'x64' + }, + runtime: { + electron: 'dev', + chrome: 'dev', + node: 'dev' + }, + paths: { + userData: 'dev-renderer', + store: 'dev-renderer', + logs: 'dev-renderer' + }, + store: { + bytes: null, + exercises: state.exercises.length, + meals: state.meals.length, + challenges: state.challenges.length, + history: history.length + }, + updater: updaterStatus, + games, + gsi: { + running: games.some((game) => game.integrationActive), + port: 38087, + baseUrl: 'http://127.0.0.1:38087' + }, + meetingActive: false + } +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 98cd6f2..68ade4d 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -7,20 +7,27 @@ import ReminderApp from './ReminderApp' import { ThemeProvider } from './providers/ThemeProvider' import { installRendererErrorReporting } from './lib/reporting' -installRendererErrorReporting() - const params = new URLSearchParams(window.location.search) const which = params.get('window') ?? 'main' // reducedMotion="user" — framer-motion сам читает системную настройку // «уменьшить движение» и глушит transform/layout-анимации (оставляя opacity). // Один источник истины для обоих окон и всех motion-компонентов. -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - {which === 'reminder' ? : } - - - -) +async function bootstrap(): Promise { + if (import.meta.env.DEV && !window.api) { + const { installDevApi } = await import('./lib/dev-api') + installDevApi() + } + installRendererErrorReporting() + ReactDOM.createRoot(document.getElementById('root')!).render( + + + + {which === 'reminder' ? : } + + + + ) +} + +void bootstrap() diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 2d99bd8..c63458d 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -201,7 +201,7 @@ export default function Dashboard(): JSX.Element {
-
+
{t('dashboard.header.date', { date: today })}
@@ -1054,7 +1054,7 @@ function planItemMeta(item: PlanItem, t: TFn): string { : t('dashboard.plan.kind.meal') } if (item.goal !== undefined) { - return t('dashboard.plan.item.remaining', { + return t('dashboard.plan.item.remaining_reps', { n: item.remainingReps ?? 0 }) } diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 7d0c547..f901ba8 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -5,6 +5,7 @@ import { FolderOpen, Languages, Palette, + Power, RefreshCw } from 'lucide-react' import { useAppStore } from '../store/appStore' @@ -18,7 +19,7 @@ import { ConfirmModal } from '../components/ui/ConfirmModal' import { Skeleton } from '../components/ui/Skeleton' import { Spinner } from '../components/ui/Spinner' import { RELEASE_NOTES } from '@shared/release-notes' -import { translate, useT } from '../i18n' +import { translate, useT, type TFn } from '../i18n' import type { DiagnosticsInfo, Language, @@ -35,7 +36,7 @@ export default function SettingsPage(): JSX.Element { if (!settings) return (
@@ -52,8 +53,8 @@ export default function SettingsPage(): JSX.Element { return (
-
-
+
+
{t('settings.kicker')}
@@ -62,6 +63,13 @@ export default function SettingsPage(): JSX.Element {
+ + patch({ globalEnabled: !settings.globalEnabled }) + } + /> + } @@ -86,7 +94,7 @@ export default function SettingsPage(): JSX.Element { /> - + + patch({ theme: v as Theme })} + options={[ + { value: 'system', label: t('settings.theme.system') }, + { value: 'light', label: t('settings.theme.light') }, + { value: 'dark', label: t('settings.theme.dark') } + ]} last /> + patch({ globalEnabled: v })} + /> - - - patch({ theme: v as Theme })} - options={[ - { value: 'system', label: t('settings.theme.system') }, - { value: 'light', label: t('settings.theme.light') }, - { value: 'dark', label: t('settings.theme.dark') } - ]} - last - /> - - @@ -242,6 +251,135 @@ export default function SettingsPage(): JSX.Element { ) } +function SettingsStatusPanel({ + settings, + onToggleGlobal +}: { + settings: SettingsType + onToggleGlobal: () => void +}): JSX.Element { + const { t } = useT() + return ( +
+
+
+
+ +
+
+
+ {t('settings.status.kicker')} +
+

+ {settings.globalEnabled + ? t('settings.status.title.on') + : t('settings.status.title.off')} +

+

+ {settingsStatusHint(settings, t)} +

+
+
+ +
+ +
+ + + + +
+
+ ) +} + +function StatusPill({ + label, + value, + active, + tone = 'success' +}: { + label: string + value: string + active: boolean + tone?: 'success' | 'info' +}): JSX.Element { + const activeClass = tone === 'info' ? 'text-info' : 'text-success' + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} + +function settingsStatusHint(settings: SettingsType, t: TFn): string { + if (!settings.globalEnabled) return t('settings.status.hint.paused') + if (settings.quietHours.enabled) { + return t('settings.status.hint.quiet', { + from: settings.quietHours.from, + to: settings.quietHours.to + }) + } + if (!settings.startWithWindows) return t('settings.status.hint.autostart') + return t('settings.status.hint.ready') +} + function DiagnosticsCard(): JSX.Element { const { t, lang } = useT() const [info, setInfo] = useState(null) diff --git a/src/shared/release-notes.ts b/src/shared/release-notes.ts index 4423889..b37cffd 100644 --- a/src/shared/release-notes.ts +++ b/src/shared/release-notes.ts @@ -21,6 +21,60 @@ export type ReleaseNoteItem = { export type ReleaseNotes = Record export const RELEASE_NOTES: Record = { + '0.6.6': { + ru: [ + { + title: 'Настройки стали панелью состояния', + detail: + 'Сверху видно, работают ли напоминания, включены ли тихие часы, встречи и запуск вместе с Windows.', + tag: 'new' + }, + { + title: 'Главный экран стал точнее по текстам', + detail: + 'Дата больше не кричит заглавными буквами, а цели показывают понятные единицы: “осталось 30 раз”.', + tag: 'fix' + }, + { + title: 'Сводные карточки читаются лучше', + detail: + 'Длинные значения в карточках больше не обрезаются, а сетка спокойнее держит десктопные ширины.', + tag: 'fix' + }, + { + title: 'Безопасный стенд для проверки интерфейса', + detail: + 'Добавлен dev:renderer: можно открыть UI в браузере с демо-данными и не трогать реальные настройки.', + tag: 'new' + } + ], + en: [ + { + title: 'Settings now show app status first', + detail: + 'The top panel shows whether reminders, quiet hours, meeting pause and Windows autostart are active.', + tag: 'new' + }, + { + title: 'Overview copy is clearer', + detail: + 'The date no longer gets artificial capitalization, and goals show units like “30 reps left”.', + tag: 'fix' + }, + { + title: 'Summary cards are easier to read', + detail: + 'Long values no longer get clipped, and the card grid behaves better on desktop widths.', + tag: 'fix' + }, + { + title: 'Safe renderer preview for UI checks', + detail: + 'Added dev:renderer so the UI can be opened with demo data without touching real settings.', + tag: 'new' + } + ] + }, '0.6.5': { ru: [ { diff --git a/vite.renderer.config.mjs b/vite.renderer.config.mjs new file mode 100644 index 0000000..cd0a131 --- /dev/null +++ b/vite.renderer.config.mjs @@ -0,0 +1,19 @@ +import { resolve } from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + root: resolve('src/renderer'), + resolve: { + alias: { + '@renderer': resolve('src/renderer/src'), + '@shared': resolve('src/shared') + } + }, + plugins: [react()], + server: { + host: '127.0.0.1', + port: 5173, + strictPort: true + } +})