== История и стрики (#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>
228 lines
5.9 KiB
TypeScript
228 lines
5.9 KiB
TypeScript
import { ipcMain, nativeTheme, systemPreferences, BrowserWindow, app, shell } from 'electron'
|
|
import { IPC } from '@shared/ipc'
|
|
import type { Challenge, Exercise, GameId, Settings } from '@shared/types'
|
|
import {
|
|
addChallenge,
|
|
addExercise,
|
|
clearHistory,
|
|
deleteChallenge,
|
|
deleteExercise,
|
|
getHistory,
|
|
getState,
|
|
markDone,
|
|
setGameEnabled,
|
|
skip,
|
|
snooze,
|
|
updateChallenge,
|
|
updateExercise,
|
|
updateSettings
|
|
} from './store'
|
|
import { broadcastState } from './state-actions'
|
|
import { setAutostart, isAutostartEnabled } from './autostart'
|
|
import { setPaused, forceCheck } from './scheduler'
|
|
import { hideReminderWindow, getMainWindow } from './windows'
|
|
import {
|
|
broadcastGames,
|
|
installGame,
|
|
listGamesStatus,
|
|
simulateMatchEnd,
|
|
toggleGame,
|
|
uninstallGame
|
|
} from './games/registry'
|
|
import {
|
|
checkForUpdates,
|
|
downloadUpdate,
|
|
getUpdaterStatus,
|
|
quitAndInstall
|
|
} from './updater'
|
|
|
|
export function registerIpc(): void {
|
|
ipcMain.handle(IPC.getState, () => {
|
|
const state = getState()
|
|
state.settings.startWithWindows = isAutostartEnabled()
|
|
return state
|
|
})
|
|
|
|
ipcMain.handle(
|
|
IPC.addExercise,
|
|
(_e, input: Omit<Exercise, 'id' | 'nextFireAt' | 'lastDoneAt'>) => {
|
|
const ex = addExercise(input)
|
|
broadcastState()
|
|
return ex
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(IPC.updateExercise, (_e, id: string, patch: Partial<Exercise>) => {
|
|
const ex = updateExercise(id, patch)
|
|
broadcastState()
|
|
return ex
|
|
})
|
|
|
|
ipcMain.handle(IPC.deleteExercise, (_e, id: string) => {
|
|
const ok = deleteExercise(id)
|
|
broadcastState()
|
|
return ok
|
|
})
|
|
|
|
ipcMain.handle(IPC.toggleExercise, (_e, id: string, enabled: boolean) => {
|
|
const patch: Partial<Exercise> = { enabled }
|
|
if (enabled) {
|
|
const ex = getState().exercises.find((e) => e.id === id)
|
|
if (ex) patch.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
|
}
|
|
const ex = updateExercise(id, patch)
|
|
broadcastState()
|
|
return ex
|
|
})
|
|
|
|
ipcMain.handle(
|
|
IPC.markDone,
|
|
(_e, id: string, actualReps?: number) => {
|
|
const ex = markDone(id, actualReps)
|
|
broadcastState()
|
|
return ex
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(IPC.snooze, (_e, id: string, minutes: number) => {
|
|
const ex = snooze(id, minutes)
|
|
broadcastState()
|
|
return ex
|
|
})
|
|
|
|
ipcMain.handle(IPC.skip, (_e, id: string) => {
|
|
const ex = skip(id)
|
|
broadcastState()
|
|
return ex
|
|
})
|
|
|
|
ipcMain.handle(IPC.updateSettings, (_e, patch: Partial<Settings>) => {
|
|
if (patch.startWithWindows !== undefined) {
|
|
setAutostart(patch.startWithWindows)
|
|
}
|
|
const merged: Partial<Settings> = { ...patch }
|
|
if (patch.startWithWindows !== undefined) {
|
|
merged.startWithWindows = isAutostartEnabled()
|
|
}
|
|
const settings = updateSettings(merged)
|
|
broadcastState()
|
|
return settings
|
|
})
|
|
|
|
ipcMain.handle(IPC.getAccentColor, () => {
|
|
try {
|
|
return '#' + systemPreferences.getAccentColor()
|
|
} catch {
|
|
return '#5B8DEF'
|
|
}
|
|
})
|
|
|
|
ipcMain.handle(IPC.getOsTheme, () =>
|
|
nativeTheme.shouldUseDarkColors ? 'dark' : 'light'
|
|
)
|
|
|
|
ipcMain.handle(IPC.pauseAll, () => setPaused(true))
|
|
ipcMain.handle(IPC.resumeAll, () => {
|
|
setPaused(false)
|
|
forceCheck()
|
|
})
|
|
|
|
ipcMain.handle(IPC.quit, () => app.quit())
|
|
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())
|
|
|
|
ipcMain.on(IPC.minimizeMain, (event) => {
|
|
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
|
})
|
|
|
|
ipcMain.on(IPC.closeMain, () => {
|
|
const main = getMainWindow()
|
|
if (!main) return
|
|
if (getState().settings.minimizeToTray) main.hide()
|
|
else main.close()
|
|
})
|
|
|
|
ipcMain.on(IPC.hideMain, () => getMainWindow()?.hide())
|
|
|
|
// Games
|
|
ipcMain.handle(IPC.gamesList, async () => listGamesStatus())
|
|
|
|
ipcMain.handle(IPC.gameInstall, async (_e, id: GameId) => {
|
|
const status = await installGame(id)
|
|
setGameEnabled(id, true)
|
|
await toggleGame(id, true)
|
|
const all = await listGamesStatus()
|
|
broadcastGames(all)
|
|
broadcastState()
|
|
return status
|
|
})
|
|
|
|
ipcMain.handle(IPC.gameUninstall, async (_e, id: GameId) => {
|
|
const status = await uninstallGame(id)
|
|
setGameEnabled(id, false)
|
|
const all = await listGamesStatus()
|
|
broadcastGames(all)
|
|
broadcastState()
|
|
return status
|
|
})
|
|
|
|
ipcMain.handle(IPC.gameToggle, async (_e, id: GameId, enabled: boolean) => {
|
|
setGameEnabled(id, enabled)
|
|
await toggleGame(id, enabled)
|
|
const all = await listGamesStatus()
|
|
broadcastGames(all)
|
|
broadcastState()
|
|
})
|
|
|
|
ipcMain.handle(IPC.gameOpenLaunchOptions, (_e, _id: GameId) => {
|
|
// Opens Steam's library; user manually adds launch options.
|
|
shell.openExternal('steam://nav/games/details/570')
|
|
})
|
|
|
|
// Challenges
|
|
ipcMain.handle(IPC.addChallenge, (_e, input: Omit<Challenge, 'id'>) => {
|
|
const c = addChallenge(input)
|
|
broadcastState()
|
|
return c
|
|
})
|
|
ipcMain.handle(
|
|
IPC.updateChallenge,
|
|
(_e, id: string, patch: Partial<Challenge>) => {
|
|
const c = updateChallenge(id, patch)
|
|
broadcastState()
|
|
return c
|
|
}
|
|
)
|
|
ipcMain.handle(IPC.deleteChallenge, (_e, id: string) => {
|
|
const ok = deleteChallenge(id)
|
|
broadcastState()
|
|
return ok
|
|
})
|
|
ipcMain.handle(IPC.toggleChallenge, (_e, id: string, enabled: boolean) => {
|
|
const c = updateChallenge(id, { enabled })
|
|
broadcastState()
|
|
return c
|
|
})
|
|
|
|
ipcMain.handle(IPC.closeMatchSummary, () => hideReminderWindow())
|
|
|
|
// Dev helper: simulate a match end with given stats.
|
|
ipcMain.handle(
|
|
'dev:simulateMatchEnd',
|
|
(_e, id: GameId, stats: Record<string, number>) => {
|
|
simulateMatchEnd(id, stats)
|
|
}
|
|
)
|
|
|
|
// Auto-updater
|
|
ipcMain.handle(IPC.updaterStatus, () => getUpdaterStatus())
|
|
ipcMain.handle(IPC.updaterCheck, () => checkForUpdates())
|
|
ipcMain.handle(IPC.updaterDownload, () => downloadUpdate())
|
|
ipcMain.handle(IPC.updaterInstall, () => quitAndInstall())
|
|
|
|
// History
|
|
ipcMain.handle(IPC.getHistory, (_e, sinceMs?: number) => getHistory(sinceMs))
|
|
ipcMain.handle(IPC.clearHistory, (_e, beforeTs?: number) =>
|
|
clearHistory(beforeTs)
|
|
)
|
|
}
|