feat(settings): add status-first control center

This commit is contained in:
Codex
2026-06-08 13:19:20 +07:00
parent 544db9cb04
commit 349ce51c67
13 changed files with 961 additions and 66 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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 {
<ErrorBoundary>
<HashRouter>
<div className="h-screen w-screen flex flex-col bg-bg">
<Titlebar onMenuClick={() => setMobileNavOpen(true)} />
<Titlebar onMenuClick={() => setCompactNavOpen(true)} />
<div className="flex-1 flex overflow-hidden">
<Sidebar
mobileOpen={mobileNavOpen}
onMobileClose={() => setMobileNavOpen(false)}
compactOpen={compactNavOpen}
onCompactClose={() => setCompactNavOpen(false)}
/>
<main className="flex-1 overflow-hidden min-w-0">
{hydrated ? (
<ErrorBoundary>
<RoutedPages onNav={() => setMobileNavOpen(false)} />
<RoutedPages onNav={() => setCompactNavOpen(false)} />
</ErrorBoundary>
) : (
// Skeleton на время гидрации — settings (и язык) ещё не

View File

@@ -40,9 +40,10 @@ export function InsightGrid({
}): JSX.Element {
return (
<div
className={['grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8', className].join(
' '
)}
className={[
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mb-8',
className
].join(' ')}
>
{children}
</div>
@@ -85,10 +86,10 @@ export function InsightCard({
{icon}
</div>
<div className="min-w-0">
<div className="text-[12px] uppercase tracking-[0.06em] text-text/50 font-bold truncate">
<div className="text-[12px] uppercase tracking-[0.06em] text-text/50 font-bold leading-tight">
{label}
</div>
<div className="font-display text-[22px] font-bold leading-none mt-1 truncate">
<div className="font-display text-[22px] font-bold leading-tight mt-1 break-words">
{value}
</div>
{hint && (

View File

@@ -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<HTMLElement | null>(null)
const lastFocusedRef = useRef<HTMLElement | null>(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({
</aside>
<AnimatePresence>
{mobileOpen && (
{compactOpen && (
<motion.div
className="md:hidden fixed inset-0 z-50 flex"
initial={{ opacity: 0 }}
@@ -123,7 +124,7 @@ export function Sidebar({
>
<motion.div
className="absolute inset-0 bg-black/30 backdrop-blur-md"
onClick={onMobileClose}
onClick={onCompactClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -141,13 +142,13 @@ export function Sidebar({
transition={{ type: 'spring', stiffness: 420, damping: 38 }}
>
<button
onClick={onMobileClose}
onClick={onCompactClose}
className="absolute top-3 right-3 w-8 h-8 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 transition-colors active:scale-90"
aria-label={t('btn.close')}
>
<X size={14} strokeWidth={2.5} />
</button>
<SidebarContent onNav={onMobileClose} />
<SidebarContent onNav={onCompactClose} />
</motion.aside>
</motion.div>
)}
@@ -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 (
<>
<div className="px-5 pt-7 pb-6">
@@ -214,10 +216,17 @@ function SidebarContent({ onNav }: { onNav?: () => void }): JSX.Element {
<div className="mt-auto px-5 pb-5">
<div className="flex items-center gap-2 text-[11px] text-text/45">
<span className="relative flex h-1.5 w-1.5">
{running && (
<span className="absolute inline-flex h-full w-full rounded-full bg-success opacity-60 animate-ping" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
)}
<span
className={[
'relative inline-flex rounded-full h-1.5 w-1.5',
running ? 'bg-success' : 'bg-warning'
].join(' ')}
/>
</span>
{t('sidebar.status_tracking')}
{running ? t('sidebar.status_tracking') : t('sidebar.status_paused')}
</div>
</div>
</>

View File

@@ -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',

View File

@@ -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<T> = (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<Handler<AppState>>()
const tickHandlers = new Set<Handler<Tick[]>>()
const historyHandlers = new Set<Handler<void>>()
const gamesHandlers = new Set<Handler<GameStatus[]>>()
const updaterHandlers = new Set<Handler<UpdaterStatus>>()
const themeHandlers = new Set<Handler<'light' | 'dark'>>()
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<T>(set: Set<Handler<T>>, handler: Handler<T>): () => 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<Settings>) => {
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
}
}

View File

@@ -7,15 +7,19 @@ 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(
async function bootstrap(): Promise<void> {
if (import.meta.env.DEV && !window.api) {
const { installDevApi } = await import('./lib/dev-api')
installDevApi()
}
installRendererErrorReporting()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MotionConfig reducedMotion="user">
<ThemeProvider>
@@ -23,4 +27,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</ThemeProvider>
</MotionConfig>
</React.StrictMode>
)
)
}
void bootstrap()

View File

@@ -201,7 +201,7 @@ export default function Dashboard(): JSX.Element {
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div className="min-w-0">
<div className="text-[14px] text-text/65 font-semibold capitalize">
<div className="text-[14px] text-text/65 font-semibold">
{t('dashboard.header.date', { date: today })}
</div>
<div className="mt-2 flex flex-wrap items-center gap-3">
@@ -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
})
}

View File

@@ -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 (
<div
className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12 space-y-5"
className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12 space-y-5"
role="status"
aria-label={t('settings.loading')}
>
@@ -52,8 +53,8 @@ export default function SettingsPage(): JSX.Element {
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="mb-8">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
<div className="mb-6">
<div className="text-[14px] text-text/65 font-semibold">
{t('settings.kicker')}
</div>
@@ -62,6 +63,13 @@ export default function SettingsPage(): JSX.Element {
</h1>
</div>
<SettingsStatusPanel
settings={settings}
onToggleGlobal={() =>
patch({ globalEnabled: !settings.globalEnabled })
}
/>
<InsightGrid>
<InsightCard
icon={<Bell size={17} strokeWidth={2.5} />}
@@ -86,7 +94,7 @@ export default function SettingsPage(): JSX.Element {
/>
</InsightGrid>
<SectionHeader title={t('settings.section.language')} />
<SectionHeader title={t('settings.section.interface')} />
<Card className="mb-6">
<SelectRow
label={t('settings.language.label')}
@@ -97,12 +105,29 @@ export default function SettingsPage(): JSX.Element {
{ value: 'ru', label: t('settings.language.ru') },
{ value: 'en', label: t('settings.language.en') }
]}
/>
<SelectRow
label={t('settings.theme.label')}
hint={t('settings.theme.hint')}
value={settings.theme}
onChange={(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
/>
</Card>
<SectionHeader title={t('settings.section.reminders')} />
<Card className="mb-6">
<ToggleRow
label={t('settings.global.label')}
hint={t('settings.global.hint')}
checked={settings.globalEnabled}
onChange={(v) => patch({ globalEnabled: v })}
/>
<SelectRow
label={t('settings.notification_mode.label')}
hint={t('settings.notification_mode.hint')}
@@ -204,22 +229,6 @@ export default function SettingsPage(): JSX.Element {
/>
</Card>
<SectionHeader title={t('settings.section.appearance')} />
<Card className="mb-6">
<SelectRow
label={t('settings.theme.label')}
hint={t('settings.theme.hint')}
value={settings.theme}
onChange={(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
/>
</Card>
<SectionHeader title={t('settings.section.updates')} />
<UpdaterCard />
@@ -242,6 +251,135 @@ export default function SettingsPage(): JSX.Element {
)
}
function SettingsStatusPanel({
settings,
onToggleGlobal
}: {
settings: SettingsType
onToggleGlobal: () => void
}): JSX.Element {
const { t } = useT()
return (
<section className="mb-6 rounded-3xl bg-surface p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<div
className={[
'w-11 h-11 rounded-2xl grid place-items-center text-white shrink-0',
settings.globalEnabled ? 'bg-success' : 'bg-warning'
].join(' ')}
>
<Power size={20} strokeWidth={2.5} />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] uppercase tracking-[0.06em] text-text/50 font-bold">
{t('settings.status.kicker')}
</div>
<h2 className="font-display text-[22px] font-bold leading-tight mt-1">
{settings.globalEnabled
? t('settings.status.title.on')
: t('settings.status.title.off')}
</h2>
<p className="text-[14px] text-text/62 mt-1 leading-relaxed max-w-xl break-words">
{settingsStatusHint(settings, t)}
</p>
</div>
</div>
<Button
type="button"
variant={settings.globalEnabled ? 'tinted' : 'filled'}
onClick={onToggleGlobal}
className="self-start sm:self-auto"
>
{settings.globalEnabled ? t('btn.pause') : t('btn.start')}
</Button>
</div>
<div className="mt-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
<StatusPill
label={t('settings.status.reminders')}
value={
settings.globalEnabled
? t('settings.status.on')
: t('settings.status.off')
}
active={settings.globalEnabled}
/>
<StatusPill
label={t('settings.status.quiet')}
value={
settings.quietHours.enabled
? `${settings.quietHours.from}-${settings.quietHours.to}`
: t('settings.status.off')
}
active={settings.quietHours.enabled}
tone="info"
/>
<StatusPill
label={t('settings.status.meetings')}
value={
settings.meetingAutoPause
? t('settings.status.on')
: t('settings.status.off')
}
active={settings.meetingAutoPause}
tone="info"
/>
<StatusPill
label={t('settings.status.autostart')}
value={
settings.startWithWindows
? t('settings.status.on')
: t('settings.status.off')
}
active={settings.startWithWindows}
/>
</div>
</section>
)
}
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 (
<div className="rounded-2xl bg-surface-2 px-3.5 py-3 min-w-0">
<div className="text-[12px] text-text/50 font-semibold leading-tight">
{label}
</div>
<div
className={[
'mt-1 text-[14px] font-bold leading-tight break-words',
active ? activeClass : 'text-text/55'
].join(' ')}
>
{value}
</div>
</div>
)
}
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<DiagnosticsInfo | null>(null)

View File

@@ -21,6 +21,60 @@ export type ReleaseNoteItem = {
export type ReleaseNotes = Record<Language, ReleaseNoteItem[]>
export const RELEASE_NOTES: Record<string, ReleaseNotes> = {
'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: [
{

19
vite.renderer.config.mjs Normal file
View File

@@ -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
}
})