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:
AnRil
2026-05-18 23:21:27 +07:00
parent f3367e09de
commit f0dc5b2cc3
10 changed files with 724 additions and 96 deletions

View File

@@ -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<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>) => {
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<Exercise>) => {
(_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<Exercise> = { 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<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()
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<Settings>) => {
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<Challenge, 'id'>) => {
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<Challenge>) => {
(_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())

View File

@@ -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)
}
/**
* 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 {
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 {

View File

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

View File

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

311
src/main/validate.ts Normal file
View 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
}