/** * Категория напоминания. По умолчанию `exercise` — для совместимости со * старыми state'ами (поле optional). Категория влияет на: * - tint иконки в карточке (hydration синий, eyes фиолетовый и т.д.) * - текст в окне напоминания («Время попить» вместо «Время тренировки») * - подсчёт повторений: для hydration/eyes/posture `reps` обычно = 1 * (это не «N раз», а просто «сделай»). */ export type ReminderCategory = 'exercise' | 'hydration' | 'eyes' | 'posture' export const REMINDER_CATEGORIES: ReminderCategory[] = [ 'exercise', 'hydration', 'eyes', 'posture' ] export type Exercise = { id: string name: string reps: number icon: string intervalMinutes: number enabled: boolean nextFireAt: number lastDoneAt?: number /** Default 'exercise' если undefined — обратная совместимость. */ category?: ReminderCategory } export type NotificationMode = 'toast' | 'modal' | 'both' export type Theme = 'light' | 'dark' | 'system' export type Language = 'ru' | 'en' /** * Hours when reminders are silenced. `from`/`to` are "HH:MM" 24h strings, * `days` are weekday indices 0=Sun..6=Sat. Empty `days` = applies every day. * If `to <= from` the window wraps across midnight (e.g. 22:00 → 07:00). */ export type QuietHours = { enabled: boolean from: string to: string /** Days when the quiet window is active. */ days: number[] } export type Settings = { globalEnabled: boolean notificationMode: NotificationMode soundEnabled: boolean /** * TTS голос диктора в окне напоминания: «Время приседать. Десять раз». * Полезно когда работаешь head-down (например пишешь код) — beep можно * пропустить, голос — нет. */ voicePromptsEnabled: boolean startWithWindows: boolean minimizeToTray: boolean startMinimized: boolean theme: Theme language: Language snoozeMinutes: number quietHours: QuietHours } /** * State, видимое renderer'у (через IPC.getState и evtStateChanged). * `history` намеренно НЕ включена — она достигает 10k записей × ~50 байт = * 500KB JSON, и шлать её на каждый markDone/snooze/etc слишком дорого. * Renderer запрашивает историю отдельно через `getHistory()` IPC (с опц. * `sinceMs` для инкрементальной подгрузки). */ export type AppState = { exercises: Exercise[] settings: Settings challenges: Challenge[] gamesEnabled: Partial> } /** Persisted shape — расширяет AppState историей (живёт только в main). */ export type PersistedState = AppState & { history?: HistoryEntry[] } export type HistoryAction = 'done' | 'skip' | 'snooze' export type HistoryEntry = { /** ms epoch */ ts: number exerciseId: string action: HistoryAction /** When user did less than planned. Only meaningful for `done`. */ actualReps?: number } export type Tick = { exerciseId: string msUntilFire: number enabled: boolean } export type FireEvent = { exercise: Exercise mode: NotificationMode } export type GameId = 'dota2' export const GAME_STATS: Record = { dota2: [ 'deaths', 'kills', 'assists', 'last_hits', 'denies', 'duration_min' ] as const } export type GameStat = | 'deaths' | 'kills' | 'assists' | 'last_hits' | 'denies' | 'duration_min' export const STAT_LABELS: Record = { deaths: 'смертей', kills: 'убийств', assists: 'ассистов', last_hits: 'ласт-хитов', denies: 'денаев', duration_min: 'минут матча' } export const STAT_LABELS_EN: Record = { deaths: 'deaths', kills: 'kills', assists: 'assists', last_hits: 'last hits', denies: 'denies', duration_min: 'match minutes' } export function statLabel(stat: GameStat, lang: Language): string { return (lang === 'en' ? STAT_LABELS_EN : STAT_LABELS)[stat] } export type Challenge = { id: string name: string gameId: GameId stat: GameStat multiplier: number exerciseName: string icon: string enabled: boolean } export type LaunchOptionStatus = 'applied' | 'queued' | 'no_user' | 'not_needed' export type GameStatus = { id: GameId name: string installed: boolean installPath?: string integrationActive: boolean // cfg installed + listener running launchOption?: string // e.g. "-gamestateintegration" launchOptionStatus: LaunchOptionStatus steamRunning?: boolean // helps the UI explain queued state enabled: boolean } export type ChallengeResult = { challengeId: string name: string icon: string exerciseName: string reps: number statValue: number /** Pre-localised label for backward compat; renderer prefers `stat`. */ statLabel: string /** Stat key; renderer uses this to localise on demand. */ stat?: GameStat } export type MatchSummary = { gameId: GameId gameName: string durationMs: number won?: boolean results: ChallengeResult[] } export const DEFAULT_SETTINGS: Settings = { globalEnabled: true, notificationMode: 'modal', soundEnabled: true, voicePromptsEnabled: false, // opt-in — на работе с коллегами может смущать startWithWindows: false, minimizeToTray: true, startMinimized: false, theme: 'light', language: 'ru', snoozeMinutes: 5, quietHours: { enabled: false, from: '22:00', to: '08:00', days: [0, 1, 2, 3, 4, 5, 6] } } const HHMM_RE = /^(\d{1,2}):(\d{2})$/ /** Parse `HH:MM` into minutes-since-midnight, or `null` if malformed. */ function parseHHMM(s: string): number | null { const m = HHMM_RE.exec(s) if (!m) return null const h = Number(m[1]) const min = Number(m[2]) if (!Number.isFinite(h) || !Number.isFinite(min)) return null if (h < 0 || h > 23 || min < 0 || min > 59) return null return h * 60 + min } /** * Returns true if `now` falls inside the quiet window. Handles wrap-around * windows (e.g. 22:00 → 08:00) AND day-of-week filtering correctly: when the * window started the previous day (we're in the AM half of a wrap-around), * the day filter is evaluated against the START day, not the current day. * * Example: from=22:00, to=07:00, days=[Mon..Fri]. At Sat 02:00 the window * is active (started Fri 22:00 — Friday is in the filter). At Mon 01:00 the * window is NOT active (would have started Sun 22:00 — Sunday is excluded). * * Malformed `from`/`to` strings (after a corrupt state file) return false. */ export function isQuietAt(qh: QuietHours, now: Date): boolean { if (!qh.enabled) return false const fromMin = parseHHMM(qh.from) const toMin = parseHHMM(qh.to) if (fromMin === null || toMin === null) return false if (fromMin === toMin) return false const cur = now.getHours() * 60 + now.getMinutes() const todayDow = now.getDay() // 0..6, 0=Sunday const yesterdayDow = (todayDow + 6) % 7 // Helper: is this day included by the filter? const dayActive = (dow: number): boolean => qh.days.length === 0 || qh.days.includes(dow) if (fromMin < toMin) { // Same-day window — start day is `todayDow`. if (!dayActive(todayDow)) return false return cur >= fromMin && cur < toMin } // Wrap-around window. Either: // - cur >= fromMin: window started TODAY at fromMin → check todayDow // - cur < toMin: window started YESTERDAY at fromMin → check yesterdayDow if (cur >= fromMin) return dayActive(todayDow) if (cur < toMin) return dayActive(yesterdayDow) return false } export const SAMPLE_EXERCISES: Omit[] = [ { name: 'Приседания', reps: 10, icon: 'Activity', intervalMinutes: 30, enabled: true, category: 'exercise' }, { name: 'Отжимания', reps: 10, icon: 'Dumbbell', intervalMinutes: 45, enabled: true, category: 'exercise' }, { name: 'Стакан воды', reps: 1, icon: 'GlassWater', intervalMinutes: 60, enabled: false, category: 'hydration' }, { name: 'Отдых глазам (20-20-20)', reps: 1, icon: 'Eye', intervalMinutes: 20, enabled: false, category: 'eyes' }, { name: 'Проверь осанку', reps: 1, icon: 'PersonStanding', intervalMinutes: 25, enabled: false, category: 'posture' } ] export type UpdaterStatus = | { kind: 'idle'; lastCheckedAt?: number } | { kind: 'unsupported'; reason: string } | { kind: 'checking' } | { kind: 'not-available'; currentVersion: string; lastCheckedAt?: number } | { kind: 'available'; version: string; releaseDate?: string } | { kind: 'downloading' percent: number transferred: number total: number bytesPerSecond: number } | { kind: 'downloaded'; version: string } | { kind: 'error'; message: string }