335 lines
9.7 KiB
TypeScript
335 lines
9.7 KiB
TypeScript
/**
|
||
* Категория напоминания. По умолчанию `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
|
||
/**
|
||
* Опциональная дневная цель в reps. Если задана, scheduler перестаёт
|
||
* fire'ить упражнение в течение дня, когда total reps за сегодня
|
||
* (учитывая actualReps в истории) достигают `dailyGoal`. Это «soft cap»
|
||
* поверх обычного interval'а: не меняет схему таймера, просто блокирует
|
||
* fires когда цель закрыта. Завтра счётчик обнуляется (по local day).
|
||
*/
|
||
dailyGoal?: number
|
||
}
|
||
|
||
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<Record<GameId, boolean>>
|
||
}
|
||
|
||
/** 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<GameId, readonly GameStat[]> = {
|
||
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<GameStat, string> = {
|
||
deaths: 'смертей',
|
||
kills: 'убийств',
|
||
assists: 'ассистов',
|
||
last_hits: 'ласт-хитов',
|
||
denies: 'денаев',
|
||
duration_min: 'минут матча'
|
||
}
|
||
|
||
export const STAT_LABELS_EN: Record<GameStat, string> = {
|
||
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<Exercise, 'id' | 'nextFireAt'>[] = [
|
||
{
|
||
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 }
|