Files
laude/src/shared/types.ts
AnRil c9d4fc237e
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
Release / Build installer + publish release (push) Has been cancelled
feat(v0.5.0): history + streak + heatmap, quiet hours, partial reps, README
== История и стрики (#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>
2026-05-18 12:41:13 +07:00

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 }