Files
laude/src/shared/types.ts
AnRil bef733a877 feat(meals): вкладка «Питание» — напоминания о еде по времени суток
Новая модель Meal — напоминание по настенным часам (time HH:MM + дни недели),
в отличие от interval-based Exercise. Отдельная вкладка «Питание» с пресетами
быстрого добавления (Завтрак/Обед/Ужин/Перекус).

- shared: тип Meal, meals в AppState, nextMealOccurrence (DST-safe), SAMPLE_MEALS,
  MEAL_PRESETS; IPC-каналы meal:* + evtFireMeal
- main: валидация (строгая HH:MM-проверка диапазона), store-мутаторы с пересчётом
  nextFireAt, scheduler.checkDueMeals (гейт только globalEnabled, grace-окно 120с,
  игнор тихих часов/ВКС), notifications.fireMealReminder, IPC-хендлеры
- renderer: вкладка Meals + MealEditor (время/дни/иконка), MealReminder в окне
  напоминания (Поел/Отложить, TTS), пункт в Sidebar, маршрут, i18n RU/EN, иконки
  UtensilsCrossed/Soup
- persistence: meals additive (без bump схемы — старые state'ы получают [])
- +24 теста (203 -> 227): nextMealOccurrence, валидаторы приёмов пищи,
  scheduler meal-gating (вкл/выкл, grace, игнор тихих часов)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 23:45:34 +07:00

457 lines
16 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
/**
* Адаптивный режим: scheduler анализирует исторические success/skip
* паттерны по часам и сдвигает fire'ы на «хорошие» часы. Не меняет
* базовый интервал — корректирует только timestamps.
*/
adaptive?: boolean
}
/**
* Приём пищи — напоминание ПО ВРЕМЕНИ СУТОК (в отличие от Exercise, который
* по интервалу). Срабатывает, когда настенные часы достигают `time` в активный
* день недели; после этого `nextFireAt` пересчитывается на следующее вхождение.
*/
export type Meal = {
id: string
name: string
/** "HH:MM" 24ч — время напоминания. */
time: string
icon: string
enabled: boolean
/** Дни недели 0=Вс..6=Сб, когда напоминать. Пусто = каждый день. */
days: number[]
/** Вычисляемое: epoch ms следующего срабатывания. */
nextFireAt: number
lastDoneAt?: number
}
/** Пресет быстрого добавления приёма пищи. Имя резолвится через i18n. */
export type MealPreset = {
/** i18n-ключ локализованного имени, напр. 'meals.preset.breakfast'. */
nameKey: string
time: string
icon: string
}
export const MEAL_PRESETS: MealPreset[] = [
{ nameKey: 'meals.preset.breakfast', time: '08:00', icon: 'Coffee' },
{ nameKey: 'meals.preset.lunch', time: '13:00', icon: 'UtensilsCrossed' },
{ nameKey: 'meals.preset.dinner', time: '19:00', icon: 'Soup' },
{ nameKey: 'meals.preset.snack', time: '16:00', icon: 'Apple' }
]
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
/**
* Авто-пауза напоминаний во время ВКС-звонков. Сканирует список процессов
* (Zoom/Teams/Discord/Webex/Slack-huddle/etc) раз в 30 сек, если хоть один
* запущен — fires не происходят. Чисто Windows (через tasklist).
*/
meetingAutoPause: boolean
startWithWindows: boolean
minimizeToTray: boolean
startMinimized: boolean
theme: Theme
language: Language
snoozeMinutes: number
quietHours: QuietHours
/**
* Версия, для которой пользователь видел экран «Что нового». Если
* `app.getVersion()` отличается — модалка показывается при следующем
* запуске и записывает текущую версию.
*/
lastSeenVersion?: string
}
/**
* State, видимое renderer'у (через IPC.getState и evtStateChanged).
* `history` намеренно НЕ включена — она достигает 10k записей × ~50 байт =
* 500KB JSON, и шлать её на каждый markDone/snooze/etc слишком дорого.
* Renderer запрашивает историю отдельно через `getHistory()` IPC (с опц.
* `sinceMs` для инкрементальной подгрузки).
*/
export type AppState = {
exercises: Exercise[]
meals: Meal[]
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'
/**
* Источник записи: обычное напоминание (от scheduler'а) или матч (челлендж).
* Используется для UI («подтянулся в матче» vs «по таймеру») и аналитики.
*/
export type HistorySource = 'reminder' | 'match'
export type HistoryEntry = {
/** ms epoch */
ts: number
/**
* Для обычных напоминаний — Exercise.id. Для challenge'ей — `challenge:<id>`
* (синтетический ключ; renderer'у не нужно искать exercise по нему).
*/
exerciseId: string
action: HistoryAction
/** When user did less than planned. Only meaningful for `done`. */
actualReps?: number
/**
* Snapshot повторений на момент записи. Гарантирует, что после удаления
* упражнения история не теряет «сколько было сделано» (раньше lookup
* `byId.get(exerciseId).reps` возвращал undefined → heatmap показывал 0).
* Для match-челленджей — фактическое число повторов из match summary.
*/
reps?: number
/** Snapshot названия упражнения/челленджа — для будущего log-view. */
name?: string
/** undefined = reminder (для обратной совместимости со старыми entries). */
source?: HistorySource
}
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 — на работе с коллегами может смущать
meetingAutoPause: true,
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
}
/**
* Следующее срабатывание приёма пищи СТРОГО после `fromMs`: ближайший день
* (включая сегодня, если время ещё не прошло), чей weekday входит в `days`
* (пустой массив = каждый день). Считает через календарную арифметику
* (`setDate`/`setHours`), а не ms — корректно переживает переход на летнее/
* зимнее время (см. урок history.ts). Малформ `time` → `fromMs + 24ч`.
*/
export function nextMealOccurrence(
time: string,
days: number[],
fromMs: number
): number {
const hm = parseHHMM(time)
const dayMs = 24 * 60 * 60 * 1000
if (hm === null) return fromMs + dayMs
const h = Math.floor(hm / 60)
const min = hm % 60
const base = new Date(fromMs)
// 0..7: ищем ближайший активный день. 7 — запас на случай, когда выбран
// единственный день недели, и сегодняшнее время уже прошло.
for (let i = 0; i <= 7; i++) {
const cand = new Date(base)
cand.setDate(cand.getDate() + i)
cand.setHours(h, min, 0, 0)
if (cand.getTime() <= fromMs) continue
const dow = cand.getDay()
if (days.length === 0 || days.includes(dow)) return cand.getTime()
}
return fromMs + dayMs
}
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'
}
]
/**
* Стартовые приёмы пищи — выключены по умолчанию (как hydration/eyes/posture).
* Пользователь включает нужные на вкладке «Питание» или добавляет свои.
*/
export const SAMPLE_MEALS: Omit<Meal, 'id' | 'nextFireAt'>[] = [
{ name: 'Завтрак', time: '08:00', icon: 'Coffee', enabled: false, days: [] },
{
name: 'Обед',
time: '13:00',
icon: 'UtensilsCrossed',
enabled: false,
days: []
},
{ name: 'Ужин', time: '19:00', icon: 'Soup', enabled: false, days: [] }
]
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 }