Files
laude/src/shared/types.ts

335 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Категория напоминания. По умолчанию `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 }