== История и стрики (#1) == - HistoryEntry { ts, exerciseId, action: done|skip|snooze, actualReps? } персистится в app-state.json, лимит 10k записей (~3 года), trim oldest 10% - markDone/snooze/skip пишут в историю; markDone принимает optional actualReps - IPC: getHistory(sinceMs?), clearHistory(beforeTs?) + preload bindings - Renderer helpers (src/renderer/src/lib/history.ts): * dayKey(ts) — YYYY-MM-DD local * dailyReps(entries, exs, dayKey) — суммирует actualReps || planned * dailyRepsRange(entries, exs, days) — для heatmap, заполняет gaps нулями * currentStreak(entries) — consecutive days, today или yesterday (grace) - Dashboard теперь 4 hero-карточки: Today (повторов за день) / Streak (дней подряд) / Next / Tracking - Новый компонент HistoryHeatmap — GitHub-style 12-недельный календарь с 5 интенсивностями, локализованными подписями дней/месяцев == Тихие часы (#2) == - shared/types.ts: QuietHours { enabled, from, to, days[] } + isQuietAt() helper с правильной обработкой wrap-around окон (22:00→08:00) - DEFAULT_SETTINGS.quietHours = disabled, 22:00→08:00, все дни - main/scheduler.ts: проверка isQuietAt перед fire; deferred fires поднимаются после окончания окна - Settings UI: новая секция "Тихие часы" с toggle, time-pickers, day-of-week pills == Сделал частично (#3) == - ReminderApp: stepper [−][число][+] вокруг счётчика повторов - При adjusted (actualReps !== exercise.reps) число подсвечивается accent и появляется подпись "Засчитаем X из Y" - markDone передаёт actualReps только если юзер реально изменил — иначе undefined чтобы история фиксировала планируемое значение чисто == README.md (#4) == - Описание, фичи, скриншоты (TODO-плейсхолдер), установка, dev-команды, архитектура, тесты, stack, ссылка на RELEASING.md - Бэйджи version / tests / platform == i18n == - ~14 новых ключей × 2 языка: dashboard.stat.today_done, streak, settings.quiet.* (3 row'а), reminder.partial == Тесты — 51 (было 33) == - shared/quiet-hours.test.ts (5): disabled, same-day, wrap-around, day filtering, zero-length - renderer/lib/history.test.ts (13): dayKey, dailyReps (planned vs actual vs ignore non-done), currentStreak (empty, today gap, consecutive, yesterday grace, multi-entry same day), dailyRepsRange Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
224 lines
5.5 KiB
TypeScript
224 lines
5.5 KiB
TypeScript
export type Exercise = {
|
|
id: string
|
|
name: string
|
|
reps: number
|
|
icon: string
|
|
intervalMinutes: number
|
|
enabled: boolean
|
|
nextFireAt: number
|
|
lastDoneAt?: 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
|
|
startWithWindows: boolean
|
|
minimizeToTray: boolean
|
|
startMinimized: boolean
|
|
theme: Theme
|
|
language: Language
|
|
snoozeMinutes: number
|
|
quietHours: QuietHours
|
|
}
|
|
|
|
export type AppState = {
|
|
exercises: Exercise[]
|
|
settings: Settings
|
|
challenges: Challenge[]
|
|
gamesEnabled: Partial<Record<GameId, boolean>>
|
|
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,
|
|
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]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if `now` falls inside the quiet window. Handles wrap-around
|
|
* windows (e.g. 22:00 → 08:00). Exposed from shared so both main scheduler
|
|
* and renderer settings UI can use the same logic.
|
|
*/
|
|
export function isQuietAt(qh: QuietHours, now: Date): boolean {
|
|
if (!qh.enabled) return false
|
|
const dow = now.getDay() // 0..6
|
|
if (qh.days.length > 0 && !qh.days.includes(dow)) return false
|
|
const [fh, fm] = qh.from.split(':').map(Number)
|
|
const [th, tm] = qh.to.split(':').map(Number)
|
|
const cur = now.getHours() * 60 + now.getMinutes()
|
|
const fromMin = fh * 60 + fm
|
|
const toMin = th * 60 + tm
|
|
if (fromMin === toMin) return false
|
|
if (fromMin < toMin) {
|
|
// Same-day window.
|
|
return cur >= fromMin && cur < toMin
|
|
}
|
|
// Wraps midnight: active if after `from` today OR before `to` today.
|
|
return cur >= fromMin || cur < toMin
|
|
}
|
|
|
|
export const SAMPLE_EXERCISES: Omit<Exercise, 'id' | 'nextFireAt'>[] = [
|
|
{ name: 'Приседания', reps: 10, icon: 'Activity', intervalMinutes: 30, enabled: true },
|
|
{ name: 'Отжимания', reps: 10, icon: 'Dumbbell', intervalMinutes: 45, enabled: true },
|
|
{ name: 'Растяжка спины', reps: 1, icon: 'StretchHorizontal', intervalMinutes: 60, enabled: false }
|
|
]
|
|
|
|
export type UpdaterStatus =
|
|
| { kind: 'idle' }
|
|
| { kind: 'unsupported'; reason: string }
|
|
| { kind: 'checking' }
|
|
| { kind: 'not-available'; currentVersion: string }
|
|
| { kind: 'available'; version: string; releaseDate?: string }
|
|
| {
|
|
kind: 'downloading'
|
|
percent: number
|
|
transferred: number
|
|
total: number
|
|
bytesPerSecond: number
|
|
}
|
|
| { kind: 'downloaded'; version: string }
|
|
| { kind: 'error'; message: string }
|
|
|