feat: a11y + Error Boundary + IPC validation + schema migrations
Second pass through the audit punch-list. ESLint and Prettier now clean
(0 errors, 0 warnings), typecheck clean, 53 tests pass.
ACCESSIBILITY (Modal)
- Full focus trap: Tab/Shift-Tab cycle within the dialog and never
escape to the underlying page.
- Focus restoration: closing returns focus to the trigger button.
- First focusable child is focused on open (skipping the X button).
- aria-labelledby links the dialog to its <h2> via useId().
- Close button's hardcoded "Закрыть" replaced with i18n key.
ERROR RECOVERY
- Add ErrorBoundary component (class — only way) with localized
fallback and a "try again" reset button. Stack trace shown only in
dev. Wrapped around the whole App + a nested boundary around the
routed pages so a crash in one route doesn't blank the chrome.
- Module-level guard on subscribeToBackend so React 18 StrictMode's
dev-mode double-mount doesn't subscribe twice.
- Loading placeholder is now blank (was hardcoded Russian "Загрузка…"
that English users would see during initial hydration).
TRAY i18n
- 5 tray strings now follow the current settings.language. Falls back
to Russian when the store isn't loaded yet or the lang is unknown.
- refreshMenu() called on settings.language change and on
pauseAll/resumeAll so the pause label stays in sync with state.
IPC VALIDATION (src/main/validate.ts)
- Hand-rolled validators for every renderer-supplied payload:
exercise input/patch, challenge input/patch, settings patch, id,
actualReps, snoozeMinutes. Range-check numeric fields
(intervalMinutes ∈ [1, 1440], reps ∈ [1, 9999], multiplier ∈ [0,
1000], snooze ∈ [1, 1440]), cap string lengths at 200, restrict
enums (theme/lang/notify-mode/stat) to known values, validate
quietHours.from/to with HH:MM regex and dedup quietHours.days.
- Every ipcMain.handle for mutations now runs the validator first and
returns null on rejection instead of pushing junk into the store.
A compromised renderer can no longer corrupt persisted state via
out-of-range numbers or wrong-type fields.
SCHEMA MIGRATIONS (src/main/store.ts)
- Add __schemaVersion field to persisted state with CURRENT = 1.
- MIGRATIONS map: { 0: (s) => s } as a no-op seed; future structural
changes (e.g. quietHours shape revision) get a single explicit slot.
- runMigrations() applies migrations in order; coerce() normalises the
result into a fully-formed AppState. Both first-write and every
flush() persist the version field.
EXHAUSTIVE-DEPS WARNINGS
- Dashboard: memoise `exercises` so downstream useMemos don't fire on
every parent render; gate the history fetch on exercises change
instead of any state change.
- HistoryHeatmap: wrap `weeks` in useMemo so monthLabels' deps are
stable.
LINT POLISH
- updater.ts: refactor a Prettier-vs-no-extra-semi conflict by
extracting the cast into a local binding.
- Remove dead import of `Challenge` from ipc.ts (now imported via
validators).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Language, Record<string, string>> = {
|
||||
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user