diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4d58781..1f467fa 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -7,7 +7,7 @@ import { shell } from 'electron' import { IPC } from '@shared/ipc' -import type { Challenge, Exercise, GameId, Settings } from '@shared/types' +import type { Exercise, GameId, Settings } from '@shared/types' import { addChallenge, addExercise, @@ -28,6 +28,7 @@ import { broadcastState } from './state-actions' import { setAutostart, isAutostartEnabled } from './autostart' import { setPaused, forceCheck } from './scheduler' import { hideReminderWindow, getMainWindow } from './windows' +import { refreshMenu } from './tray' import { broadcastGames, installGame, @@ -42,6 +43,16 @@ import { getUpdaterStatus, quitAndInstall } from './updater' +import { + validateActualReps, + validateChallengeInput, + validateChallengePatch, + validateExerciseInput, + validateExercisePatch, + validateId, + validateSettingsPatch, + validateSnoozeMinutes +} from './validate' export function registerIpc(): void { ipcMain.handle(IPC.getState, () => { @@ -50,60 +61,78 @@ export function registerIpc(): void { return state }) - ipcMain.handle( - IPC.addExercise, - (_e, input: Omit) => { - const ex = addExercise(input) - broadcastState() - return ex - } - ) + ipcMain.handle(IPC.addExercise, (_e, input: unknown) => { + const safe = validateExerciseInput(input) + if (!safe) return null + const ex = addExercise(safe) + broadcastState() + return ex + }) ipcMain.handle( IPC.updateExercise, - (_e, id: string, patch: Partial) => { + (_e, idRaw: unknown, patchRaw: unknown) => { + const id = validateId(idRaw) + const patch = validateExercisePatch(patchRaw) + if (!id || !patch) return null const ex = updateExercise(id, patch) broadcastState() return ex } ) - ipcMain.handle(IPC.deleteExercise, (_e, id: string) => { + ipcMain.handle(IPC.deleteExercise, (_e, idRaw: unknown) => { + const id = validateId(idRaw) + if (!id) return false const ok = deleteExercise(id) broadcastState() return ok }) - ipcMain.handle(IPC.toggleExercise, (_e, id: string, enabled: boolean) => { - const patch: Partial = { enabled } - if (enabled) { - const ex = getState().exercises.find((e) => e.id === id) - if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 + ipcMain.handle( + IPC.toggleExercise, + (_e, idRaw: unknown, enabledRaw: unknown) => { + const id = validateId(idRaw) + if (!id || typeof enabledRaw !== 'boolean') return null + const patch: Partial = { enabled: enabledRaw } + if (enabledRaw) { + const ex = getState().exercises.find((e) => e.id === id) + if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000 + } + const ex = updateExercise(id, patch) + broadcastState() + return ex } - const ex = updateExercise(id, patch) + ) + + ipcMain.handle(IPC.markDone, (_e, idRaw: unknown, repsRaw?: unknown) => { + const id = validateId(idRaw) + if (!id) return null + const ex = markDone(id, validateActualReps(repsRaw)) broadcastState() return ex }) - ipcMain.handle(IPC.markDone, (_e, id: string, actualReps?: number) => { - const ex = markDone(id, actualReps) - broadcastState() - return ex - }) - - ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => { + ipcMain.handle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => { + const id = validateId(idRaw) + const minutes = validateSnoozeMinutes(minRaw) + if (!id || minutes === null) return null const ex = snooze(id, minutes) broadcastState() return ex }) - ipcMain.handle(IPC.skip, (_e, id: string) => { + ipcMain.handle(IPC.skip, (_e, idRaw: unknown) => { + const id = validateId(idRaw) + if (!id) return null const ex = skip(id) broadcastState() return ex }) - ipcMain.handle(IPC.updateSettings, (_e, patch: Partial) => { + ipcMain.handle(IPC.updateSettings, (_e, patchRaw: unknown) => { + const patch = validateSettingsPatch(patchRaw) + if (!patch) return null if (patch.startWithWindows !== undefined) { setAutostart(patch.startWithWindows) } @@ -113,9 +142,21 @@ export function registerIpc(): void { } const settings = updateSettings(merged) broadcastState() + // Language change reflects in the tray menu next time it's opened. + if (patch.language !== undefined) refreshMenu() return settings }) + ipcMain.handle(IPC.pauseAll, () => { + setPaused(true) + refreshMenu() + }) + ipcMain.handle(IPC.resumeAll, () => { + setPaused(false) + forceCheck() + refreshMenu() + }) + ipcMain.handle(IPC.getAccentColor, () => { try { return '#' + systemPreferences.getAccentColor() @@ -128,12 +169,6 @@ export function registerIpc(): void { nativeTheme.shouldUseDarkColors ? 'dark' : 'light' ) - ipcMain.handle(IPC.pauseAll, () => setPaused(true)) - ipcMain.handle(IPC.resumeAll, () => { - setPaused(false) - forceCheck() - }) - ipcMain.handle(IPC.quit, () => app.quit()) ipcMain.handle(IPC.reminderClose, () => hideReminderWindow()) @@ -186,29 +221,41 @@ export function registerIpc(): void { }) // Challenges - ipcMain.handle(IPC.addChallenge, (_e, input: Omit) => { - const c = addChallenge(input) + ipcMain.handle(IPC.addChallenge, (_e, input: unknown) => { + const safe = validateChallengeInput(input) + if (!safe) return null + const c = addChallenge(safe) broadcastState() return c }) ipcMain.handle( IPC.updateChallenge, - (_e, id: string, patch: Partial) => { + (_e, idRaw: unknown, patchRaw: unknown) => { + const id = validateId(idRaw) + const patch = validateChallengePatch(patchRaw) + if (!id || !patch) return null const c = updateChallenge(id, patch) broadcastState() return c } ) - ipcMain.handle(IPC.deleteChallenge, (_e, id: string) => { + ipcMain.handle(IPC.deleteChallenge, (_e, idRaw: unknown) => { + const id = validateId(idRaw) + if (!id) return false const ok = deleteChallenge(id) broadcastState() return ok }) - ipcMain.handle(IPC.toggleChallenge, (_e, id: string, enabled: boolean) => { - const c = updateChallenge(id, { enabled }) - broadcastState() - return c - }) + ipcMain.handle( + IPC.toggleChallenge, + (_e, idRaw: unknown, enabledRaw: unknown) => { + const id = validateId(idRaw) + if (!id || typeof enabledRaw !== 'boolean') return null + const c = updateChallenge(id, { enabled: enabledRaw }) + broadcastState() + return c + } + ) ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow()) diff --git a/src/main/store.ts b/src/main/store.ts index bb8fa2f..b918ec5 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -97,15 +97,83 @@ function quarantineCorrupt(p: string, reason: string): void { } } -function isValidParsed(v: unknown): v is Partial { +function isValidParsed(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v) } +/** + * Current persisted-state schema version. Bump this and add a migration to + * MIGRATIONS whenever the on-disk shape changes in a non-additive way. + * + * Additive changes (new optional fields, new entries in `gamesEnabled`) do + * NOT need a version bump — DEFAULT_SETTINGS spread + the `?? []` guards in + * `coerce()` handle them gracefully. + */ +const CURRENT_SCHEMA_VERSION = 1 + +type StoredState = Record & { __schemaVersion?: number } + +/** + * Migrations are applied in order until the stored version matches CURRENT. + * Each fn returns the next-version state. The receiver may freely mutate. + * + * Note: the v0→v1 migration is a no-op — v1 is the inaugural schema. The + * machinery exists so future structural changes (e.g. splitting + * `quietHours.days` into a per-window record) have a single explicit place + * to live. + */ +const MIGRATIONS: Record StoredState> = { + 0: (s) => s +} + +function runMigrations(s: StoredState): StoredState { + let version = typeof s.__schemaVersion === 'number' ? s.__schemaVersion : 0 + let cursor = s + while (version < CURRENT_SCHEMA_VERSION) { + const fn = MIGRATIONS[version] + if (!fn) { + console.warn( + `[store] no migration from v${version}; skipping ahead and hoping for the best.` + ) + break + } + cursor = fn(cursor) + version += 1 + } + cursor.__schemaVersion = CURRENT_SCHEMA_VERSION + return cursor +} + +/** Coerce a (possibly partial) migrated state into a fully-formed AppState. */ +function coerce(s: StoredState): AppState { + return { + exercises: Array.isArray(s.exercises) ? (s.exercises as Exercise[]) : [], + settings: { + ...DEFAULT_SETTINGS, + ...(isValidParsed(s.settings) ? (s.settings as Partial) : {}) + }, + challenges: Array.isArray(s.challenges) + ? (s.challenges as Challenge[]) + : [], + gamesEnabled: isValidParsed(s.gamesEnabled) + ? (s.gamesEnabled as Partial>) + : {}, + history: Array.isArray(s.history) ? (s.history as HistoryEntry[]) : [] + } +} + function load(): AppState { const p = getStorePath() if (!existsSync(p)) { const initial = makeInitial() - atomicWrite(p, JSON.stringify(initial, null, 2)) + atomicWrite( + p, + JSON.stringify( + { __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial }, + null, + 2 + ) + ) return initial } let raw: string @@ -126,16 +194,7 @@ function load(): AppState { quarantineCorrupt(p, `expected object, got ${typeof parsed}`) return makeInitial() } - return { - exercises: Array.isArray(parsed.exercises) ? parsed.exercises : [], - settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) }, - challenges: Array.isArray(parsed.challenges) ? parsed.challenges : [], - gamesEnabled: - typeof parsed.gamesEnabled === 'object' && parsed.gamesEnabled !== null - ? parsed.gamesEnabled - : {}, - history: Array.isArray(parsed.history) ? parsed.history : [] - } + return coerce(runMigrations(parsed)) } function appendHistory( @@ -207,7 +266,10 @@ function atomicWrite(path: string, contents: string): void { function flush(): void { if (!cache) return - atomicWrite(getStorePath(), JSON.stringify(cache, null, 2)) + // Persist the schema version alongside the state so future migrations know + // where to pick up from. The renderer never reads this key. + const payload = { __schemaVersion: CURRENT_SCHEMA_VERSION, ...cache } + atomicWrite(getStorePath(), JSON.stringify(payload, null, 2)) } function scheduleWrite(): void { diff --git a/src/main/tray.ts b/src/main/tray.ts index 8cf46df..0bf0417 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -3,9 +3,45 @@ import { join } from 'node:path' import { showMainWindow } from './windows' import { isPaused, setPaused, forceCheck } from './scheduler' import { snoozeAll } from './state-actions' +import { getSettings } from './store' +import type { Language } from '@shared/types' let tray: Tray | null = null +/** + * Minimal tray-side localisation. The renderer's full i18n dict lives in + * `src/renderer/src/i18n/dict.ts` and isn't reachable from the main process + * tsconfig, so we keep the 5 strings the tray actually uses here. + */ +const TRAY_STRINGS: Record> = { + ru: { + open: 'Открыть', + pause: 'Пауза напоминаний', + resume: 'Возобновить напоминания', + snooze15: 'Отложить все на 15 мин', + quit: 'Выход' + }, + en: { + open: 'Open', + pause: 'Pause reminders', + resume: 'Resume reminders', + snooze15: 'Snooze all 15 min', + quit: 'Quit' + } +} + +function trayLabel(key: string): string { + // getSettings reads from cache; if the store hasn't loaded yet (very early + // boot) it lazily reads from disk. Defaults to 'ru' if anything goes wrong. + let lang: Language = 'ru' + try { + lang = getSettings().language ?? 'ru' + } catch { + /* keep default */ + } + return TRAY_STRINGS[lang]?.[key] ?? TRAY_STRINGS.ru[key] ?? key +} + function resolveTrayIcon(): Electron.NativeImage { // Try resources/, fallback to a transparent 16x16 if missing during dev. const candidates = [ @@ -35,10 +71,10 @@ export function refreshMenu(): void { if (!tray) return const paused = isPaused() const menu = Menu.buildFromTemplate([ - { label: 'Открыть', click: () => showMainWindow() }, + { label: trayLabel('open'), click: () => showMainWindow() }, { type: 'separator' }, { - label: paused ? 'Возобновить напоминания' : 'Пауза напоминаний', + label: paused ? trayLabel('resume') : trayLabel('pause'), click: () => { setPaused(!paused) refreshMenu() @@ -46,12 +82,12 @@ export function refreshMenu(): void { } }, { - label: 'Отложить все на 15 мин', + label: trayLabel('snooze15'), click: () => snoozeAll(15) }, { type: 'separator' }, { - label: 'Выход', + label: trayLabel('quit'), click: () => { app.quit() } diff --git a/src/main/updater.ts b/src/main/updater.ts index 56c2f18..4878ecc 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -26,7 +26,8 @@ function setStatus(s: UpdaterStatus): void { // Preserve lastCheckedAt across status transitions where applicable. if (s.kind === 'not-available' || s.kind === 'idle') { if (lastCheckedAt && !('lastCheckedAt' in s)) { - ;(s as { lastCheckedAt?: number }).lastCheckedAt = lastCheckedAt + const withTs = s as { lastCheckedAt?: number } + withTs.lastCheckedAt = lastCheckedAt } } currentStatus = s diff --git a/src/main/validate.ts b/src/main/validate.ts new file mode 100644 index 0000000..8a4b12d --- /dev/null +++ b/src/main/validate.ts @@ -0,0 +1,311 @@ +/** + * Hand-rolled runtime validators for IPC payloads. + * + * TypeScript types are erased at compile time — a compromised or buggy + * renderer can still send arbitrary JSON across the IPC boundary. These + * helpers enforce shape, type and range BEFORE the data hits the store. + * + * Philosophy: be lenient with unknown fields (drop them silently), strict + * about known fields (reject the call if a known field is the wrong type + * or out of range). Never throw to the renderer; return a sanitised value + * or `null` and the caller decides what to do. + */ + +import type { + Challenge, + Exercise, + GameStat, + Settings, + Theme, + Language, + NotificationMode +} from '@shared/types' + +const MAX_STR_LEN = 200 +const VALID_THEMES: Theme[] = ['system', 'light', 'dark'] +const VALID_LANGS: Language[] = ['ru', 'en'] +const VALID_NOTIFY: NotificationMode[] = ['toast', 'modal', 'both'] +const VALID_STATS: GameStat[] = [ + 'deaths', + 'kills', + 'assists', + 'last_hits', + 'denies', + 'duration_min' +] +const HHMM_RE = /^\d{1,2}:\d{2}$/ + +function isObj(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +function safeStr(v: unknown, max = MAX_STR_LEN): string | undefined { + if (typeof v !== 'string') return undefined + if (v.length === 0 || v.length > max) return undefined + return v +} + +function intInRange(v: unknown, min: number, max: number): number | undefined { + if (typeof v !== 'number' || !Number.isFinite(v)) return undefined + const n = Math.trunc(v) + if (n < min || n > max) return undefined + return n +} + +function numInRange(v: unknown, min: number, max: number): number | undefined { + if (typeof v !== 'number' || !Number.isFinite(v)) return undefined + if (v < min || v > max) return undefined + return v +} + +function bool(v: unknown): boolean | undefined { + return typeof v === 'boolean' ? v : undefined +} + +function oneOf( + v: unknown, + allowed: readonly T[] +): T | undefined { + return typeof v === 'string' && (allowed as readonly string[]).includes(v) + ? (v as T) + : undefined +} + +// ----------------------------------------------------------------------- +// Exercise validators +// ----------------------------------------------------------------------- + +export function validateExerciseInput( + raw: unknown +): Omit | null { + if (!isObj(raw)) return null + const name = safeStr(raw.name) + const reps = intInRange(raw.reps, 1, 9999) + const intervalMinutes = intInRange(raw.intervalMinutes, 1, 24 * 60) + const icon = safeStr(raw.icon, 64) ?? 'Activity' + const enabled = bool(raw.enabled) ?? true + if ( + name === undefined || + reps === undefined || + intervalMinutes === undefined + ) { + return null + } + return { name, reps, intervalMinutes, icon, enabled } +} + +export function validateExercisePatch( + raw: unknown +): Partial> | null { + if (!isObj(raw)) return null + const out: Partial> = {} + if ('name' in raw) { + const v = safeStr(raw.name) + if (v === undefined) return null + out.name = v + } + if ('reps' in raw) { + const v = intInRange(raw.reps, 1, 9999) + if (v === undefined) return null + out.reps = v + } + if ('intervalMinutes' in raw) { + const v = intInRange(raw.intervalMinutes, 1, 24 * 60) + if (v === undefined) return null + out.intervalMinutes = v + } + if ('icon' in raw) { + const v = safeStr(raw.icon, 64) + if (v === undefined) return null + out.icon = v + } + if ('enabled' in raw) { + const v = bool(raw.enabled) + if (v === undefined) return null + out.enabled = v + } + // Allow scheduler-controlled fields to be patched (used by store.markDone + // through this same boundary), but range-check them. + if ('nextFireAt' in raw) { + const v = numInRange(raw.nextFireAt, 0, Number.MAX_SAFE_INTEGER) + if (v === undefined) return null + out.nextFireAt = v + } + if ('lastDoneAt' in raw) { + const v = numInRange(raw.lastDoneAt, 0, Number.MAX_SAFE_INTEGER) + if (v === undefined) return null + out.lastDoneAt = v + } + return out +} + +// ----------------------------------------------------------------------- +// Challenge validators +// ----------------------------------------------------------------------- + +export function validateChallengeInput( + raw: unknown +): Omit | null { + if (!isObj(raw)) return null + const name = safeStr(raw.name) + const gameId = safeStr(raw.gameId, 32) + const stat = oneOf(raw.stat, VALID_STATS) + const multiplier = numInRange(raw.multiplier, 0, 1000) + const exerciseName = safeStr(raw.exerciseName) + const icon = safeStr(raw.icon, 64) ?? 'Activity' + const enabled = bool(raw.enabled) ?? true + if ( + name === undefined || + gameId === undefined || + stat === undefined || + multiplier === undefined || + exerciseName === undefined + ) { + return null + } + return { + name, + gameId: gameId as Challenge['gameId'], + stat, + multiplier, + exerciseName, + icon, + enabled + } +} + +export function validateChallengePatch( + raw: unknown +): Partial> | null { + if (!isObj(raw)) return null + const out: Partial> = {} + if ('name' in raw) { + const v = safeStr(raw.name) + if (v === undefined) return null + out.name = v + } + if ('exerciseName' in raw) { + const v = safeStr(raw.exerciseName) + if (v === undefined) return null + out.exerciseName = v + } + if ('stat' in raw) { + const v = oneOf(raw.stat, VALID_STATS) + if (v === undefined) return null + out.stat = v + } + if ('multiplier' in raw) { + const v = numInRange(raw.multiplier, 0, 1000) + if (v === undefined) return null + out.multiplier = v + } + if ('icon' in raw) { + const v = safeStr(raw.icon, 64) + if (v === undefined) return null + out.icon = v + } + if ('enabled' in raw) { + const v = bool(raw.enabled) + if (v === undefined) return null + out.enabled = v + } + return out +} + +// ----------------------------------------------------------------------- +// Settings validators +// ----------------------------------------------------------------------- + +export function validateSettingsPatch(raw: unknown): Partial | null { + if (!isObj(raw)) return null + const out: Partial = {} + if ('globalEnabled' in raw) { + const v = bool(raw.globalEnabled) + if (v === undefined) return null + out.globalEnabled = v + } + if ('startWithWindows' in raw) { + const v = bool(raw.startWithWindows) + if (v === undefined) return null + out.startWithWindows = v + } + if ('startMinimized' in raw) { + const v = bool(raw.startMinimized) + if (v === undefined) return null + out.startMinimized = v + } + if ('minimizeToTray' in raw) { + const v = bool(raw.minimizeToTray) + if (v === undefined) return null + out.minimizeToTray = v + } + if ('soundEnabled' in raw) { + const v = bool(raw.soundEnabled) + if (v === undefined) return null + out.soundEnabled = v + } + if ('notificationMode' in raw) { + const v = oneOf(raw.notificationMode, VALID_NOTIFY) + if (v === undefined) return null + out.notificationMode = v + } + if ('theme' in raw) { + const v = oneOf(raw.theme, VALID_THEMES) + if (v === undefined) return null + out.theme = v + } + if ('language' in raw) { + const v = oneOf(raw.language, VALID_LANGS) + if (v === undefined) return null + out.language = v + } + if ('snoozeMinutes' in raw) { + const v = intInRange(raw.snoozeMinutes, 1, 24 * 60) + if (v === undefined) return null + out.snoozeMinutes = v + } + if ('quietHours' in raw) { + const qh = raw.quietHours + if (!isObj(qh)) return null + const enabled = bool(qh.enabled) + const from = safeStr(qh.from, 8) + const to = safeStr(qh.to, 8) + if ( + enabled === undefined || + from === undefined || + to === undefined || + !HHMM_RE.test(from) || + !HHMM_RE.test(to) + ) { + return null + } + if (!Array.isArray(qh.days)) return null + const days: number[] = [] + for (const d of qh.days) { + const n = intInRange(d, 0, 6) + if (n === undefined) return null + if (!days.includes(n)) days.push(n) + } + out.quietHours = { enabled, from, to, days } + } + return out +} + +// ----------------------------------------------------------------------- +// Misc tiny validators +// ----------------------------------------------------------------------- + +export function validateId(raw: unknown): string | null { + // UUIDs from store.ts via randomUUID(); accept any reasonable string id. + const v = safeStr(raw, 64) + return v ?? null +} + +export function validateActualReps(raw: unknown): number | undefined { + if (raw === undefined || raw === null) return undefined + return intInRange(raw, 0, 100000) ?? undefined +} + +export function validateSnoozeMinutes(raw: unknown): number | null { + return intInRange(raw, 1, 24 * 60) ?? null +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f7866af..0d50e04 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -3,6 +3,7 @@ import { HashRouter, Route, Routes, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' import { Sidebar } from './components/Sidebar' import { Titlebar } from './components/Titlebar' +import { ErrorBoundary } from './components/ErrorBoundary' import Dashboard from './pages/Dashboard' import Exercises from './pages/Exercises' import GamesPage from './pages/Games' @@ -10,34 +11,48 @@ import ChallengesPage from './pages/Challenges' import SettingsPage from './pages/Settings' import { subscribeToBackend, useAppStore } from './store/appStore' +// Module-level guard so React 18 StrictMode's double-invocation of mount +// effects (in dev only) doesn't subscribe to backend IPC twice. +let backendSubscribed = false + export default function App(): JSX.Element { const hydrated = useAppStore((s) => s.hydrated) const [mobileNavOpen, setMobileNavOpen] = useState(false) useEffect(() => { + if (backendSubscribed) return undefined + backendSubscribed = true const unsub = subscribeToBackend() - return unsub + return () => { + backendSubscribed = false + unsub() + } }, []) return ( - -
- setMobileNavOpen(true)} /> -
- setMobileNavOpen(false)} - /> -
- {hydrated ? ( - setMobileNavOpen(false)} /> - ) : ( -
Загрузка…
- )} -
+ + +
+ setMobileNavOpen(true)} /> +
+ setMobileNavOpen(false)} + /> +
+ {hydrated ? ( + + setMobileNavOpen(false)} /> + + ) : ( + // Neutral placeholder — settings (and lang) aren't loaded yet. +
+ )} +
+
-
- + + ) } diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/src/renderer/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..24ef0ba --- /dev/null +++ b/src/renderer/src/components/ErrorBoundary.tsx @@ -0,0 +1,61 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' + +type Props = { + children: ReactNode + /** Optional render override; receives the captured error. */ + fallback?: (err: Error, reset: () => void) => ReactNode +} + +type State = { + error: Error | null +} + +/** + * Top-level error boundary so a crash in one subtree (e.g. a malformed + * history entry crashing HistoryHeatmap) does not blank the whole window. + * React class components are still the only way to implement this. + */ +export class ErrorBoundary extends Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // No remote telemetry — log to the local console so a curious user + // (or dev tools session) can capture it. + console.error('[ErrorBoundary]', error, info.componentStack) + } + + reset = (): void => this.setState({ error: null }) + + render(): ReactNode { + const { error } = this.state + if (!error) return this.props.children + + if (this.props.fallback) return this.props.fallback(error, this.reset) + + return ( +
+
+ Что-то пошло не так +
+
+ {error.message} +
+ + {import.meta.env.DEV && error.stack && ( +
+            {error.stack}
+          
+ )} +
+ ) + } +} diff --git a/src/renderer/src/components/HistoryHeatmap.tsx b/src/renderer/src/components/HistoryHeatmap.tsx index b139055..48c9322 100644 --- a/src/renderer/src/components/HistoryHeatmap.tsx +++ b/src/renderer/src/components/HistoryHeatmap.tsx @@ -37,18 +37,21 @@ export function HistoryHeatmap({ } // Group cells into columns (weeks). Pad start so first column aligns to - // its actual week (Mon-first). - const firstDay = cells[0]?.date ?? new Date() - const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon - const padded: ({ - key: string - date: Date - reps: number - } | null)[] = [...Array(firstWeekday).fill(null), ...cells] - const weeks: (typeof padded)[] = [] - for (let i = 0; i < padded.length; i += 7) { - weeks.push(padded.slice(i, i + 7)) - } + // its actual week (Mon-first). Memoised so monthLabels' deps are stable. + const weeks = useMemo(() => { + const firstDay = cells[0]?.date ?? new Date() + const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon + const padded: ({ + key: string + date: Date + reps: number + } | null)[] = [...Array(firstWeekday).fill(null), ...cells] + const out: (typeof padded)[] = [] + for (let i = 0; i < padded.length; i += 7) { + out.push(padded.slice(i, i + 7)) + } + return out + }, [cells]) const dayLabels = lang === 'en' diff --git a/src/renderer/src/components/ui/Modal.tsx b/src/renderer/src/components/ui/Modal.tsx index 9db773d..678e9e9 100644 --- a/src/renderer/src/components/ui/Modal.tsx +++ b/src/renderer/src/components/ui/Modal.tsx @@ -1,6 +1,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { X } from 'lucide-react' -import { ReactNode, useEffect } from 'react' +import { ReactNode, useEffect, useId, useRef } from 'react' +import { useT } from '../../i18n' type Props = { open: boolean @@ -17,9 +18,25 @@ const sizeClass = { lg: 'max-w-3xl' } +/** All elements inside `root` that can receive keyboard focus. */ +function getFocusable(root: HTMLElement): HTMLElement[] { + return Array.from( + root.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ).filter((el) => el.offsetParent !== null || el === document.activeElement) +} + /** * iOS-style centred sheet. Spring-snap on enter, soft fade-out. - * Backdrop uses heavy blur for proper iOS modal feel. + * + * Accessibility: + * - role="dialog" + aria-modal="true" + aria-labelledby on the title

+ * - Focus is trapped inside the dialog while open; Tab/Shift-Tab cycle + * through focusable children and never escape to the underlying page. + * - On open the first focusable element is focused. + * - On close, focus returns to whatever was focused when the modal opened. + * - Esc closes (parent handles confirm-on-dirty if it wants). */ export function Modal({ open, @@ -29,6 +46,12 @@ export function Modal({ footer, size = 'md' }: Props): JSX.Element { + const { t } = useT() + const titleId = useId() + const sheetRef = useRef(null) + const lastFocusedRef = useRef(null) + + // Esc closes. useEffect(() => { if (!open) return const onKey = (e: KeyboardEvent): void => { @@ -38,6 +61,60 @@ export function Modal({ return () => window.removeEventListener('keydown', onKey) }, [open, onClose]) + // Focus trap + focus restore. + useEffect(() => { + if (!open) return + const previouslyFocused = document.activeElement as HTMLElement | null + lastFocusedRef.current = previouslyFocused + + // Defer focus to the next frame — framer-motion's enter animation may + // still be mounting children when this effect runs. + const raf = requestAnimationFrame(() => { + const root = sheetRef.current + if (!root) return + const focusables = getFocusable(root) + const first = focusables.find( + (el) => !el.hasAttribute('data-modal-close') + ) + ;(first ?? focusables[0])?.focus() + }) + + const onKeyDown = (e: KeyboardEvent): void => { + if (e.key !== 'Tab') return + const root = sheetRef.current + if (!root) return + const focusables = getFocusable(root) + if (focusables.length === 0) { + e.preventDefault() + return + } + const first = focusables[0] + const last = focusables[focusables.length - 1] + const active = document.activeElement as HTMLElement | null + if (e.shiftKey) { + if (active === first || !root.contains(active)) { + e.preventDefault() + last.focus() + } + } else { + if (active === last || !root.contains(active)) { + e.preventDefault() + first.focus() + } + } + } + document.addEventListener('keydown', onKeyDown, true) + + return () => { + cancelAnimationFrame(raf) + document.removeEventListener('keydown', onKeyDown, true) + // Restore focus to the trigger (button/row) that opened the modal, + // unless it was unmounted while the modal was open. + const target = lastFocusedRef.current + if (target && document.body.contains(target)) target.focus() + } + }, [open]) + return ( {open && ( @@ -50,8 +127,10 @@ export function Modal({ onClick={onClose} > {/* Header — iOS large modal title */}
-

+

{title}

diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index dc48695..5881938 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -18,15 +18,20 @@ export default function Dashboard(): JSX.Element { const [editing, setEditing] = useState(null) const { t, lang } = useT() - const exercises = state?.exercises ?? [] + // Memoise the exercises array reference so downstream useMemos don't fire + // on every render — `state?.exercises ?? []` creates a fresh array each time + // the parent re-renders even when nothing changed. + const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises]) const settings = state?.settings const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean) - // Local history mirror; reloaded whenever app-state changes. + // Local history mirror; reloaded only when exercises change (not on every + // tick or settings tweak — those don't affect history). When ticks/settings + // change we don't re-fetch. const [history, setHistory] = useState([]) useEffect(() => { void window.api.getHistory().then(setHistory) - }, [state]) + }, [exercises]) const todayDone = useMemo( () => dailyReps(history, exercises, todayKey()), @@ -34,7 +39,11 @@ export default function Dashboard(): JSX.Element { ) const streak = useMemo(() => currentStreak(history), [history]) + // `ticks` is intentionally a dep so the countdown re-evaluates each second + // even though Date.now() inside isn't a reactive dependency. Reference it + // once inside the memo so ESLint sees the dep as used. const stats = useMemo(() => { + void ticks // re-run on tick (Date.now() is the actual driver) const enabled = exercises.filter((e) => e.enabled) const next = enabled .map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))