Files
laude/src/main/ipc.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

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)
)
}