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:
129
src/main/ipc.ts
129
src/main/ipc.ts
@@ -7,7 +7,7 @@ import {
|
|||||||
shell
|
shell
|
||||||
} from 'electron'
|
} from 'electron'
|
||||||
import { IPC } from '@shared/ipc'
|
import { IPC } from '@shared/ipc'
|
||||||
import type { Challenge, Exercise, GameId, Settings } from '@shared/types'
|
import type { Exercise, GameId, Settings } from '@shared/types'
|
||||||
import {
|
import {
|
||||||
addChallenge,
|
addChallenge,
|
||||||
addExercise,
|
addExercise,
|
||||||
@@ -28,6 +28,7 @@ import { broadcastState } from './state-actions'
|
|||||||
import { setAutostart, isAutostartEnabled } from './autostart'
|
import { setAutostart, isAutostartEnabled } from './autostart'
|
||||||
import { setPaused, forceCheck } from './scheduler'
|
import { setPaused, forceCheck } from './scheduler'
|
||||||
import { hideReminderWindow, getMainWindow } from './windows'
|
import { hideReminderWindow, getMainWindow } from './windows'
|
||||||
|
import { refreshMenu } from './tray'
|
||||||
import {
|
import {
|
||||||
broadcastGames,
|
broadcastGames,
|
||||||
installGame,
|
installGame,
|
||||||
@@ -42,6 +43,16 @@ import {
|
|||||||
getUpdaterStatus,
|
getUpdaterStatus,
|
||||||
quitAndInstall
|
quitAndInstall
|
||||||
} from './updater'
|
} from './updater'
|
||||||
|
import {
|
||||||
|
validateActualReps,
|
||||||
|
validateChallengeInput,
|
||||||
|
validateChallengePatch,
|
||||||
|
validateExerciseInput,
|
||||||
|
validateExercisePatch,
|
||||||
|
validateId,
|
||||||
|
validateSettingsPatch,
|
||||||
|
validateSnoozeMinutes
|
||||||
|
} from './validate'
|
||||||
|
|
||||||
export function registerIpc(): void {
|
export function registerIpc(): void {
|
||||||
ipcMain.handle(IPC.getState, () => {
|
ipcMain.handle(IPC.getState, () => {
|
||||||
@@ -50,60 +61,78 @@ export function registerIpc(): void {
|
|||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(IPC.addExercise, (_e, input: unknown) => {
|
||||||
IPC.addExercise,
|
const safe = validateExerciseInput(input)
|
||||||
(_e, input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>) => {
|
if (!safe) return null
|
||||||
const ex = addExercise(input)
|
const ex = addExercise(safe)
|
||||||
broadcastState()
|
broadcastState()
|
||||||
return ex
|
return ex
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.updateExercise,
|
IPC.updateExercise,
|
||||||
(_e, id: string, patch: Partial<Exercise>) => {
|
(_e, idRaw: unknown, patchRaw: unknown) => {
|
||||||
|
const id = validateId(idRaw)
|
||||||
|
const patch = validateExercisePatch(patchRaw)
|
||||||
|
if (!id || !patch) return null
|
||||||
const ex = updateExercise(id, patch)
|
const ex = updateExercise(id, patch)
|
||||||
broadcastState()
|
broadcastState()
|
||||||
return ex
|
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)
|
const ok = deleteExercise(id)
|
||||||
broadcastState()
|
broadcastState()
|
||||||
return ok
|
return ok
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IPC.toggleExercise, (_e, id: string, enabled: boolean) => {
|
ipcMain.handle(
|
||||||
const patch: Partial<Exercise> = { enabled }
|
IPC.toggleExercise,
|
||||||
if (enabled) {
|
(_e, idRaw: unknown, enabledRaw: unknown) => {
|
||||||
const ex = getState().exercises.find((e) => e.id === id)
|
const id = validateId(idRaw)
|
||||||
if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
if (!id || typeof enabledRaw !== 'boolean') return null
|
||||||
|
const patch: Partial<Exercise> = { 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()
|
broadcastState()
|
||||||
return ex
|
return ex
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IPC.markDone, (_e, id: string, actualReps?: number) => {
|
ipcMain.handle(IPC.snooze, (_e, idRaw: unknown, minRaw: unknown) => {
|
||||||
const ex = markDone(id, actualReps)
|
const id = validateId(idRaw)
|
||||||
broadcastState()
|
const minutes = validateSnoozeMinutes(minRaw)
|
||||||
return ex
|
if (!id || minutes === null) return null
|
||||||
})
|
|
||||||
|
|
||||||
ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => {
|
|
||||||
const ex = snooze(id, minutes)
|
const ex = snooze(id, minutes)
|
||||||
broadcastState()
|
broadcastState()
|
||||||
return ex
|
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)
|
const ex = skip(id)
|
||||||
broadcastState()
|
broadcastState()
|
||||||
return ex
|
return ex
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle(IPC.updateSettings, (_e, patch: Partial<Settings>) => {
|
ipcMain.handle(IPC.updateSettings, (_e, patchRaw: unknown) => {
|
||||||
|
const patch = validateSettingsPatch(patchRaw)
|
||||||
|
if (!patch) return null
|
||||||
if (patch.startWithWindows !== undefined) {
|
if (patch.startWithWindows !== undefined) {
|
||||||
setAutostart(patch.startWithWindows)
|
setAutostart(patch.startWithWindows)
|
||||||
}
|
}
|
||||||
@@ -113,9 +142,21 @@ export function registerIpc(): void {
|
|||||||
}
|
}
|
||||||
const settings = updateSettings(merged)
|
const settings = updateSettings(merged)
|
||||||
broadcastState()
|
broadcastState()
|
||||||
|
// Language change reflects in the tray menu next time it's opened.
|
||||||
|
if (patch.language !== undefined) refreshMenu()
|
||||||
return settings
|
return settings
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC.pauseAll, () => {
|
||||||
|
setPaused(true)
|
||||||
|
refreshMenu()
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.resumeAll, () => {
|
||||||
|
setPaused(false)
|
||||||
|
forceCheck()
|
||||||
|
refreshMenu()
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle(IPC.getAccentColor, () => {
|
ipcMain.handle(IPC.getAccentColor, () => {
|
||||||
try {
|
try {
|
||||||
return '#' + systemPreferences.getAccentColor()
|
return '#' + systemPreferences.getAccentColor()
|
||||||
@@ -128,12 +169,6 @@ export function registerIpc(): void {
|
|||||||
nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
|
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.quit, () => app.quit())
|
||||||
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
|
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
|
||||||
|
|
||||||
@@ -186,29 +221,41 @@ export function registerIpc(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Challenges
|
// Challenges
|
||||||
ipcMain.handle(IPC.addChallenge, (_e, input: Omit<Challenge, 'id'>) => {
|
ipcMain.handle(IPC.addChallenge, (_e, input: unknown) => {
|
||||||
const c = addChallenge(input)
|
const safe = validateChallengeInput(input)
|
||||||
|
if (!safe) return null
|
||||||
|
const c = addChallenge(safe)
|
||||||
broadcastState()
|
broadcastState()
|
||||||
return c
|
return c
|
||||||
})
|
})
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.updateChallenge,
|
IPC.updateChallenge,
|
||||||
(_e, id: string, patch: Partial<Challenge>) => {
|
(_e, idRaw: unknown, patchRaw: unknown) => {
|
||||||
|
const id = validateId(idRaw)
|
||||||
|
const patch = validateChallengePatch(patchRaw)
|
||||||
|
if (!id || !patch) return null
|
||||||
const c = updateChallenge(id, patch)
|
const c = updateChallenge(id, patch)
|
||||||
broadcastState()
|
broadcastState()
|
||||||
return c
|
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)
|
const ok = deleteChallenge(id)
|
||||||
broadcastState()
|
broadcastState()
|
||||||
return ok
|
return ok
|
||||||
})
|
})
|
||||||
ipcMain.handle(IPC.toggleChallenge, (_e, id: string, enabled: boolean) => {
|
ipcMain.handle(
|
||||||
const c = updateChallenge(id, { enabled })
|
IPC.toggleChallenge,
|
||||||
broadcastState()
|
(_e, idRaw: unknown, enabledRaw: unknown) => {
|
||||||
return c
|
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())
|
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow())
|
||||||
|
|
||||||
|
|||||||
@@ -97,15 +97,83 @@ function quarantineCorrupt(p: string, reason: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidParsed(v: unknown): v is Partial<AppState> {
|
function isValidParsed(v: unknown): v is Record<string, unknown> {
|
||||||
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
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<string, unknown> & { __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<number, (s: StoredState) => 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<Settings>) : {})
|
||||||
|
},
|
||||||
|
challenges: Array.isArray(s.challenges)
|
||||||
|
? (s.challenges as Challenge[])
|
||||||
|
: [],
|
||||||
|
gamesEnabled: isValidParsed(s.gamesEnabled)
|
||||||
|
? (s.gamesEnabled as Partial<Record<GameId, boolean>>)
|
||||||
|
: {},
|
||||||
|
history: Array.isArray(s.history) ? (s.history as HistoryEntry[]) : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function load(): AppState {
|
function load(): AppState {
|
||||||
const p = getStorePath()
|
const p = getStorePath()
|
||||||
if (!existsSync(p)) {
|
if (!existsSync(p)) {
|
||||||
const initial = makeInitial()
|
const initial = makeInitial()
|
||||||
atomicWrite(p, JSON.stringify(initial, null, 2))
|
atomicWrite(
|
||||||
|
p,
|
||||||
|
JSON.stringify(
|
||||||
|
{ __schemaVersion: CURRENT_SCHEMA_VERSION, ...initial },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
)
|
||||||
return initial
|
return initial
|
||||||
}
|
}
|
||||||
let raw: string
|
let raw: string
|
||||||
@@ -126,16 +194,7 @@ function load(): AppState {
|
|||||||
quarantineCorrupt(p, `expected object, got ${typeof parsed}`)
|
quarantineCorrupt(p, `expected object, got ${typeof parsed}`)
|
||||||
return makeInitial()
|
return makeInitial()
|
||||||
}
|
}
|
||||||
return {
|
return coerce(runMigrations(parsed))
|
||||||
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 : []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendHistory(
|
function appendHistory(
|
||||||
@@ -207,7 +266,10 @@ function atomicWrite(path: string, contents: string): void {
|
|||||||
|
|
||||||
function flush(): void {
|
function flush(): void {
|
||||||
if (!cache) return
|
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 {
|
function scheduleWrite(): void {
|
||||||
|
|||||||
@@ -3,9 +3,45 @@ import { join } from 'node:path'
|
|||||||
import { showMainWindow } from './windows'
|
import { showMainWindow } from './windows'
|
||||||
import { isPaused, setPaused, forceCheck } from './scheduler'
|
import { isPaused, setPaused, forceCheck } from './scheduler'
|
||||||
import { snoozeAll } from './state-actions'
|
import { snoozeAll } from './state-actions'
|
||||||
|
import { getSettings } from './store'
|
||||||
|
import type { Language } from '@shared/types'
|
||||||
|
|
||||||
let tray: Tray | null = null
|
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 {
|
function resolveTrayIcon(): Electron.NativeImage {
|
||||||
// Try resources/, fallback to a transparent 16x16 if missing during dev.
|
// Try resources/, fallback to a transparent 16x16 if missing during dev.
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -35,10 +71,10 @@ export function refreshMenu(): void {
|
|||||||
if (!tray) return
|
if (!tray) return
|
||||||
const paused = isPaused()
|
const paused = isPaused()
|
||||||
const menu = Menu.buildFromTemplate([
|
const menu = Menu.buildFromTemplate([
|
||||||
{ label: 'Открыть', click: () => showMainWindow() },
|
{ label: trayLabel('open'), click: () => showMainWindow() },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: paused ? 'Возобновить напоминания' : 'Пауза напоминаний',
|
label: paused ? trayLabel('resume') : trayLabel('pause'),
|
||||||
click: () => {
|
click: () => {
|
||||||
setPaused(!paused)
|
setPaused(!paused)
|
||||||
refreshMenu()
|
refreshMenu()
|
||||||
@@ -46,12 +82,12 @@ export function refreshMenu(): void {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Отложить все на 15 мин',
|
label: trayLabel('snooze15'),
|
||||||
click: () => snoozeAll(15)
|
click: () => snoozeAll(15)
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Выход',
|
label: trayLabel('quit'),
|
||||||
click: () => {
|
click: () => {
|
||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ function setStatus(s: UpdaterStatus): void {
|
|||||||
// Preserve lastCheckedAt across status transitions where applicable.
|
// Preserve lastCheckedAt across status transitions where applicable.
|
||||||
if (s.kind === 'not-available' || s.kind === 'idle') {
|
if (s.kind === 'not-available' || s.kind === 'idle') {
|
||||||
if (lastCheckedAt && !('lastCheckedAt' in s)) {
|
if (lastCheckedAt && !('lastCheckedAt' in s)) {
|
||||||
;(s as { lastCheckedAt?: number }).lastCheckedAt = lastCheckedAt
|
const withTs = s as { lastCheckedAt?: number }
|
||||||
|
withTs.lastCheckedAt = lastCheckedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentStatus = s
|
currentStatus = s
|
||||||
|
|||||||
311
src/main/validate.ts
Normal file
311
src/main/validate.ts
Normal file
@@ -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<string, unknown> {
|
||||||
|
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<T extends string>(
|
||||||
|
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<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'> | 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<Omit<Exercise, 'id'>> | null {
|
||||||
|
if (!isObj(raw)) return null
|
||||||
|
const out: Partial<Omit<Exercise, 'id'>> = {}
|
||||||
|
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<Challenge, 'id'> | 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<Omit<Challenge, 'id'>> | null {
|
||||||
|
if (!isObj(raw)) return null
|
||||||
|
const out: Partial<Omit<Challenge, 'id'>> = {}
|
||||||
|
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<Settings> | null {
|
||||||
|
if (!isObj(raw)) return null
|
||||||
|
const out: Partial<Settings> = {}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { HashRouter, Route, Routes, useLocation } from 'react-router-dom'
|
|||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { Sidebar } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import { Titlebar } from './components/Titlebar'
|
import { Titlebar } from './components/Titlebar'
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import Exercises from './pages/Exercises'
|
import Exercises from './pages/Exercises'
|
||||||
import GamesPage from './pages/Games'
|
import GamesPage from './pages/Games'
|
||||||
@@ -10,34 +11,48 @@ import ChallengesPage from './pages/Challenges'
|
|||||||
import SettingsPage from './pages/Settings'
|
import SettingsPage from './pages/Settings'
|
||||||
import { subscribeToBackend, useAppStore } from './store/appStore'
|
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 {
|
export default function App(): JSX.Element {
|
||||||
const hydrated = useAppStore((s) => s.hydrated)
|
const hydrated = useAppStore((s) => s.hydrated)
|
||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (backendSubscribed) return undefined
|
||||||
|
backendSubscribed = true
|
||||||
const unsub = subscribeToBackend()
|
const unsub = subscribeToBackend()
|
||||||
return unsub
|
return () => {
|
||||||
|
backendSubscribed = false
|
||||||
|
unsub()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<ErrorBoundary>
|
||||||
<div className="h-screen w-screen flex flex-col bg-bg">
|
<HashRouter>
|
||||||
<Titlebar onMenuClick={() => setMobileNavOpen(true)} />
|
<div className="h-screen w-screen flex flex-col bg-bg">
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<Titlebar onMenuClick={() => setMobileNavOpen(true)} />
|
||||||
<Sidebar
|
<div className="flex-1 flex overflow-hidden">
|
||||||
mobileOpen={mobileNavOpen}
|
<Sidebar
|
||||||
onMobileClose={() => setMobileNavOpen(false)}
|
mobileOpen={mobileNavOpen}
|
||||||
/>
|
onMobileClose={() => setMobileNavOpen(false)}
|
||||||
<main className="flex-1 overflow-hidden min-w-0">
|
/>
|
||||||
{hydrated ? (
|
<main className="flex-1 overflow-hidden min-w-0">
|
||||||
<RoutedPages onNav={() => setMobileNavOpen(false)} />
|
{hydrated ? (
|
||||||
) : (
|
<ErrorBoundary>
|
||||||
<div className="p-8 text-text/45">Загрузка…</div>
|
<RoutedPages onNav={() => setMobileNavOpen(false)} />
|
||||||
)}
|
</ErrorBoundary>
|
||||||
</main>
|
) : (
|
||||||
|
// Neutral placeholder — settings (and lang) aren't loaded yet.
|
||||||
|
<div className="p-8 text-text/45" />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</HashRouter>
|
||||||
</HashRouter>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
src/renderer/src/components/ErrorBoundary.tsx
Normal file
61
src/renderer/src/components/ErrorBoundary.tsx
Normal file
@@ -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<Props, State> {
|
||||||
|
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 (
|
||||||
|
<div className="p-6 max-w-xl mx-auto text-center">
|
||||||
|
<div className="text-[15px] font-semibold mb-2">
|
||||||
|
Что-то пошло не так
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] text-text/65 mb-4 break-words">
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={this.reset}
|
||||||
|
className="h-9 px-4 rounded-xl bg-accent text-white text-[14px] font-semibold active:scale-95 transition-transform"
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
{import.meta.env.DEV && error.stack && (
|
||||||
|
<pre className="mt-6 p-3 bg-surface-2 rounded-xl text-left text-[11px] font-mono-num overflow-auto max-h-64">
|
||||||
|
{error.stack}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,18 +37,21 @@ export function HistoryHeatmap({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Group cells into columns (weeks). Pad start so first column aligns to
|
// Group cells into columns (weeks). Pad start so first column aligns to
|
||||||
// its actual week (Mon-first).
|
// its actual week (Mon-first). Memoised so monthLabels' deps are stable.
|
||||||
const firstDay = cells[0]?.date ?? new Date()
|
const weeks = useMemo(() => {
|
||||||
const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon
|
const firstDay = cells[0]?.date ?? new Date()
|
||||||
const padded: ({
|
const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon
|
||||||
key: string
|
const padded: ({
|
||||||
date: Date
|
key: string
|
||||||
reps: number
|
date: Date
|
||||||
} | null)[] = [...Array(firstWeekday).fill(null), ...cells]
|
reps: number
|
||||||
const weeks: (typeof padded)[] = []
|
} | null)[] = [...Array(firstWeekday).fill(null), ...cells]
|
||||||
for (let i = 0; i < padded.length; i += 7) {
|
const out: (typeof padded)[] = []
|
||||||
weeks.push(padded.slice(i, i + 7))
|
for (let i = 0; i < padded.length; i += 7) {
|
||||||
}
|
out.push(padded.slice(i, i + 7))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [cells])
|
||||||
|
|
||||||
const dayLabels =
|
const dayLabels =
|
||||||
lang === 'en'
|
lang === 'en'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { ReactNode, useEffect } from 'react'
|
import { ReactNode, useEffect, useId, useRef } from 'react'
|
||||||
|
import { useT } from '../../i18n'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -17,9 +18,25 @@ const sizeClass = {
|
|||||||
lg: 'max-w-3xl'
|
lg: 'max-w-3xl'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** All elements inside `root` that can receive keyboard focus. */
|
||||||
|
function getFocusable(root: HTMLElement): HTMLElement[] {
|
||||||
|
return Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>(
|
||||||
|
'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.
|
* 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 <h2>
|
||||||
|
* - 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({
|
export function Modal({
|
||||||
open,
|
open,
|
||||||
@@ -29,6 +46,12 @@ export function Modal({
|
|||||||
footer,
|
footer,
|
||||||
size = 'md'
|
size = 'md'
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
|
const { t } = useT()
|
||||||
|
const titleId = useId()
|
||||||
|
const sheetRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const lastFocusedRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// Esc closes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
@@ -38,6 +61,60 @@ export function Modal({
|
|||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [open, onClose])
|
}, [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 (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
@@ -50,8 +127,10 @@ export function Modal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
ref={sheetRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
className={[
|
className={[
|
||||||
'relative w-full bg-surface rounded-3xl shadow-sheet flex flex-col overflow-hidden',
|
'relative w-full bg-surface rounded-3xl shadow-sheet flex flex-col overflow-hidden',
|
||||||
sizeClass[size]
|
sizeClass[size]
|
||||||
@@ -64,13 +143,17 @@ export function Modal({
|
|||||||
>
|
>
|
||||||
{/* Header — iOS large modal title */}
|
{/* Header — iOS large modal title */}
|
||||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||||
<h2 className="font-display text-[20px] font-semibold tracking-tight">
|
<h2
|
||||||
|
id={titleId}
|
||||||
|
className="font-display text-[20px] font-semibold tracking-tight"
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
data-modal-close=""
|
||||||
className="w-7 h-7 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 hover:text-text transition-colors active:scale-90"
|
className="w-7 h-7 grid place-items-center rounded-full bg-surface-2 hover:bg-hairline/25 text-text/60 hover:text-text transition-colors active:scale-90"
|
||||||
aria-label="Закрыть"
|
aria-label={t('btn.close')}
|
||||||
>
|
>
|
||||||
<X size={14} strokeWidth={2.5} />
|
<X size={14} strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,15 +18,20 @@ export default function Dashboard(): JSX.Element {
|
|||||||
const [editing, setEditing] = useState<Exercise | null>(null)
|
const [editing, setEditing] = useState<Exercise | null>(null)
|
||||||
const { t, lang } = useT()
|
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 settings = state?.settings
|
||||||
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
|
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<HistoryEntry[]>([])
|
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void window.api.getHistory().then(setHistory)
|
void window.api.getHistory().then(setHistory)
|
||||||
}, [state])
|
}, [exercises])
|
||||||
|
|
||||||
const todayDone = useMemo(
|
const todayDone = useMemo(
|
||||||
() => dailyReps(history, exercises, todayKey()),
|
() => dailyReps(history, exercises, todayKey()),
|
||||||
@@ -34,7 +39,11 @@ export default function Dashboard(): JSX.Element {
|
|||||||
)
|
)
|
||||||
const streak = useMemo(() => currentStreak(history), [history])
|
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(() => {
|
const stats = useMemo(() => {
|
||||||
|
void ticks // re-run on tick (Date.now() is the actual driver)
|
||||||
const enabled = exercises.filter((e) => e.enabled)
|
const enabled = exercises.filter((e) => e.enabled)
|
||||||
const next = enabled
|
const next = enabled
|
||||||
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
|
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
|
||||||
|
|||||||
Reference in New Issue
Block a user