598 lines
16 KiB
TypeScript
598 lines
16 KiB
TypeScript
import {
|
||
DEFAULT_SETTINGS,
|
||
nextMealOccurrence,
|
||
type AppState,
|
||
type Challenge,
|
||
type DiagnosticsInfo,
|
||
type Exercise,
|
||
type GameId,
|
||
type GameStatus,
|
||
type HistoryEntry,
|
||
type Meal,
|
||
type RendererErrorReport,
|
||
type Settings,
|
||
type Tick,
|
||
type UpdaterStatus
|
||
} from '@shared/types'
|
||
|
||
type Api = Window['api']
|
||
type Handler<T> = (payload: T) => void
|
||
|
||
const now = Date.now()
|
||
|
||
let state: AppState = {
|
||
exercises: [
|
||
{
|
||
id: 'dev-ex-squats',
|
||
name: 'Приседания',
|
||
reps: 10,
|
||
icon: 'Activity',
|
||
intervalMinutes: 30,
|
||
enabled: true,
|
||
nextFireAt: now - 90_000,
|
||
lastDoneAt: now - 2 * 60 * 60 * 1000,
|
||
category: 'exercise',
|
||
dailyGoal: 40,
|
||
adaptive: true
|
||
},
|
||
{
|
||
id: 'dev-ex-eyes',
|
||
name: 'Отдых глазам 20-20-20',
|
||
reps: 1,
|
||
icon: 'Eye',
|
||
intervalMinutes: 20,
|
||
enabled: true,
|
||
nextFireAt: now + 9 * 60_000,
|
||
category: 'eyes'
|
||
},
|
||
{
|
||
id: 'dev-ex-water',
|
||
name: 'Стакан воды',
|
||
reps: 1,
|
||
icon: 'GlassWater',
|
||
intervalMinutes: 60,
|
||
enabled: true,
|
||
nextFireAt: now + 26 * 60_000,
|
||
category: 'hydration',
|
||
dailyGoal: 6
|
||
},
|
||
{
|
||
id: 'dev-ex-posture',
|
||
name: 'Проверь осанку',
|
||
reps: 1,
|
||
icon: 'PersonStanding',
|
||
intervalMinutes: 25,
|
||
enabled: false,
|
||
nextFireAt: now + 25 * 60_000,
|
||
category: 'posture'
|
||
}
|
||
],
|
||
meals: [
|
||
{
|
||
id: 'dev-meal-breakfast',
|
||
name: 'Завтрак',
|
||
time: '08:00',
|
||
icon: 'Coffee',
|
||
enabled: true,
|
||
days: [],
|
||
nextFireAt: nextMealOccurrence('08:00', [], now),
|
||
lastDoneAt: now - 5 * 60 * 60 * 1000
|
||
},
|
||
{
|
||
id: 'dev-meal-lunch',
|
||
name: 'Обед',
|
||
time: '13:00',
|
||
icon: 'UtensilsCrossed',
|
||
enabled: true,
|
||
days: [],
|
||
nextFireAt: nextMealOccurrence('13:00', [], now)
|
||
},
|
||
{
|
||
id: 'dev-meal-dinner',
|
||
name: 'Ужин',
|
||
time: '19:00',
|
||
icon: 'Soup',
|
||
enabled: false,
|
||
days: [],
|
||
nextFireAt: nextMealOccurrence('19:00', [], now)
|
||
}
|
||
],
|
||
settings: {
|
||
...DEFAULT_SETTINGS,
|
||
lastSeenVersion: '0.6.5'
|
||
},
|
||
challenges: [
|
||
{
|
||
id: 'dev-ch-deaths',
|
||
name: 'За смерти в Dota',
|
||
gameId: 'dota2',
|
||
stat: 'deaths',
|
||
multiplier: 3,
|
||
exerciseName: 'Приседания',
|
||
icon: 'Activity',
|
||
enabled: true
|
||
},
|
||
{
|
||
id: 'dev-ch-kills',
|
||
name: 'За убийства',
|
||
gameId: 'dota2',
|
||
stat: 'kills',
|
||
multiplier: 1,
|
||
exerciseName: 'Отжимания',
|
||
icon: 'Dumbbell',
|
||
enabled: false
|
||
}
|
||
],
|
||
gamesEnabled: { dota2: true }
|
||
}
|
||
|
||
let history: HistoryEntry[] = [
|
||
{
|
||
ts: now - 2 * 60 * 60 * 1000,
|
||
exerciseId: 'dev-ex-squats',
|
||
action: 'done',
|
||
reps: 10,
|
||
name: 'Приседания',
|
||
source: 'reminder'
|
||
},
|
||
{
|
||
ts: now - 5 * 60 * 60 * 1000,
|
||
exerciseId: 'meal:dev-meal-breakfast',
|
||
action: 'done',
|
||
reps: 1,
|
||
name: 'Завтрак',
|
||
source: 'meal'
|
||
},
|
||
{
|
||
ts: now - 26 * 60 * 60 * 1000,
|
||
exerciseId: 'dev-ex-eyes',
|
||
action: 'done',
|
||
reps: 1,
|
||
name: 'Отдых глазам 20-20-20',
|
||
source: 'reminder'
|
||
},
|
||
{
|
||
ts: now - 48 * 60 * 60 * 1000,
|
||
exerciseId: 'dev-ex-squats',
|
||
action: 'done',
|
||
reps: 10,
|
||
name: 'Приседания',
|
||
source: 'reminder'
|
||
}
|
||
]
|
||
|
||
let games: GameStatus[] = [
|
||
{
|
||
id: 'dota2',
|
||
name: 'Dota 2',
|
||
installed: true,
|
||
installPath:
|
||
'C:\\Program Files (x86)\\Steam\\steamapps\\common\\dota 2 beta',
|
||
integrationActive: false,
|
||
launchOption: '-gamestateintegration',
|
||
launchOptionStatus: 'queued',
|
||
steamRunning: true,
|
||
enabled: true
|
||
}
|
||
]
|
||
|
||
let updaterStatus: UpdaterStatus = {
|
||
kind: 'not-available',
|
||
currentVersion: '0.6.5',
|
||
lastCheckedAt: now - 12 * 60_000
|
||
}
|
||
|
||
const stateHandlers = new Set<Handler<AppState>>()
|
||
const tickHandlers = new Set<Handler<Tick[]>>()
|
||
const historyHandlers = new Set<Handler<void>>()
|
||
const gamesHandlers = new Set<Handler<GameStatus[]>>()
|
||
const updaterHandlers = new Set<Handler<UpdaterStatus>>()
|
||
const themeHandlers = new Set<Handler<'light' | 'dark'>>()
|
||
const emptyUnsub = (): void => undefined
|
||
let tickTimer: number | undefined
|
||
|
||
function cloneState(): AppState {
|
||
return structuredClone(state)
|
||
}
|
||
|
||
function emitState(): void {
|
||
const snapshot = cloneState()
|
||
stateHandlers.forEach((handler) => handler(snapshot))
|
||
}
|
||
|
||
function emitHistory(): void {
|
||
historyHandlers.forEach((handler) => handler())
|
||
}
|
||
|
||
function emitGames(): void {
|
||
const snapshot = structuredClone(games)
|
||
gamesHandlers.forEach((handler) => handler(snapshot))
|
||
}
|
||
|
||
function emitUpdater(): void {
|
||
updaterHandlers.forEach((handler) => handler(updaterStatus))
|
||
}
|
||
|
||
function pushHistory(entry: HistoryEntry): void {
|
||
history = [entry, ...history]
|
||
emitHistory()
|
||
}
|
||
|
||
function findExercise(id: string): Exercise {
|
||
const exercise = state.exercises.find((item) => item.id === id)
|
||
if (!exercise) throw new Error(`Unknown exercise ${id}`)
|
||
return exercise
|
||
}
|
||
|
||
function findMeal(id: string): Meal {
|
||
const meal = state.meals.find((item) => item.id === id)
|
||
if (!meal) throw new Error(`Unknown meal ${id}`)
|
||
return meal
|
||
}
|
||
|
||
function nextId(prefix: string): string {
|
||
return `${prefix}-${Math.random().toString(36).slice(2, 9)}`
|
||
}
|
||
|
||
function subscribe<T>(set: Set<Handler<T>>, handler: Handler<T>): () => void {
|
||
set.add(handler)
|
||
return () => set.delete(handler)
|
||
}
|
||
|
||
function buildTicks(): Tick[] {
|
||
return state.exercises.map((exercise) => ({
|
||
exerciseId: exercise.id,
|
||
enabled: exercise.enabled,
|
||
msUntilFire: exercise.nextFireAt - Date.now()
|
||
}))
|
||
}
|
||
|
||
if (import.meta.hot) {
|
||
import.meta.hot.dispose(() => {
|
||
if (tickTimer !== undefined) window.clearInterval(tickTimer)
|
||
})
|
||
}
|
||
|
||
export function installDevApi(): void {
|
||
if (window.api || !import.meta.env.DEV) return
|
||
|
||
const api: Api = {
|
||
getState: async () => cloneState(),
|
||
addExercise: async (input) => {
|
||
const exercise: Exercise = {
|
||
...input,
|
||
id: nextId('dev-ex'),
|
||
nextFireAt: Date.now() + input.intervalMinutes * 60_000
|
||
}
|
||
state = { ...state, exercises: [...state.exercises, exercise] }
|
||
emitState()
|
||
return structuredClone(exercise)
|
||
},
|
||
updateExercise: async (id, patch) => {
|
||
let updated = findExercise(id)
|
||
state = {
|
||
...state,
|
||
exercises: state.exercises.map((exercise) => {
|
||
if (exercise.id !== id) return exercise
|
||
updated = { ...exercise, ...patch, id }
|
||
return updated
|
||
})
|
||
}
|
||
emitState()
|
||
return structuredClone(updated)
|
||
},
|
||
deleteExercise: async (id) => {
|
||
const before = state.exercises.length
|
||
state = {
|
||
...state,
|
||
exercises: state.exercises.filter((exercise) => exercise.id !== id)
|
||
}
|
||
emitState()
|
||
return state.exercises.length !== before
|
||
},
|
||
toggleExercise: async (id, enabled) => {
|
||
return api.updateExercise(id, { enabled })
|
||
},
|
||
markDone: async (id, actualReps) => {
|
||
const exercise = findExercise(id)
|
||
const updated = await api.updateExercise(id, {
|
||
lastDoneAt: Date.now(),
|
||
nextFireAt: Date.now() + exercise.intervalMinutes * 60_000
|
||
})
|
||
pushHistory({
|
||
ts: Date.now(),
|
||
exerciseId: id,
|
||
action: 'done',
|
||
actualReps,
|
||
reps: exercise.reps,
|
||
name: exercise.name,
|
||
source: 'reminder'
|
||
})
|
||
return updated
|
||
},
|
||
snooze: async (id, minutes) => {
|
||
return api.updateExercise(id, {
|
||
nextFireAt: Date.now() + minutes * 60_000
|
||
})
|
||
},
|
||
skip: async (id) => {
|
||
const exercise = findExercise(id)
|
||
const updated = await api.updateExercise(id, {
|
||
nextFireAt: Date.now() + exercise.intervalMinutes * 60_000
|
||
})
|
||
pushHistory({
|
||
ts: Date.now(),
|
||
exerciseId: id,
|
||
action: 'skip',
|
||
reps: exercise.reps,
|
||
name: exercise.name,
|
||
source: 'reminder'
|
||
})
|
||
return updated
|
||
},
|
||
addMeal: async (input) => {
|
||
const meal: Meal = {
|
||
...input,
|
||
id: nextId('dev-meal'),
|
||
nextFireAt: nextMealOccurrence(input.time, input.days, Date.now())
|
||
}
|
||
state = { ...state, meals: [...state.meals, meal] }
|
||
emitState()
|
||
return structuredClone(meal)
|
||
},
|
||
updateMeal: async (id, patch) => {
|
||
let updated = findMeal(id)
|
||
state = {
|
||
...state,
|
||
meals: state.meals.map((meal) => {
|
||
if (meal.id !== id) return meal
|
||
updated = { ...meal, ...patch, id }
|
||
if (
|
||
(patch.time !== undefined ||
|
||
patch.days !== undefined ||
|
||
patch.enabled !== undefined) &&
|
||
patch.nextFireAt === undefined
|
||
) {
|
||
updated.nextFireAt = nextMealOccurrence(
|
||
updated.time,
|
||
updated.days,
|
||
Date.now()
|
||
)
|
||
}
|
||
return updated
|
||
})
|
||
}
|
||
emitState()
|
||
return structuredClone(updated)
|
||
},
|
||
deleteMeal: async (id) => {
|
||
const before = state.meals.length
|
||
state = { ...state, meals: state.meals.filter((meal) => meal.id !== id) }
|
||
emitState()
|
||
return state.meals.length !== before
|
||
},
|
||
toggleMeal: async (id, enabled) => api.updateMeal(id, { enabled }),
|
||
markMealDone: async (id) => {
|
||
const meal = findMeal(id)
|
||
const updated = await api.updateMeal(id, {
|
||
lastDoneAt: Date.now(),
|
||
nextFireAt: nextMealOccurrence(meal.time, meal.days, Date.now())
|
||
})
|
||
pushHistory({
|
||
ts: Date.now(),
|
||
exerciseId: `meal:${id}`,
|
||
action: 'done',
|
||
reps: 1,
|
||
name: meal.name,
|
||
source: 'meal'
|
||
})
|
||
return updated
|
||
},
|
||
updateSettings: async (patch: Partial<Settings>) => {
|
||
state = { ...state, settings: { ...state.settings, ...patch } }
|
||
if (patch.theme === 'light' || patch.theme === 'dark') {
|
||
themeHandlers.forEach((handler) =>
|
||
handler(patch.theme as 'light' | 'dark')
|
||
)
|
||
}
|
||
emitState()
|
||
return structuredClone(state.settings)
|
||
},
|
||
getAccentColor: async () => '#ff6b35',
|
||
getOsTheme: async () => 'light',
|
||
getAppVersion: async () => '0.6.5',
|
||
getMeetingActive: async () => false,
|
||
getDiagnostics: async () => diagnostics(),
|
||
openLogsFolder: async () => ({ ok: true }),
|
||
copyDiagnostics: async () => diagnostics(),
|
||
reportRendererError: async (report: RendererErrorReport) => {
|
||
console.warn('[dev-api] renderer error', report)
|
||
return true
|
||
},
|
||
pauseAll: async () => {
|
||
await api.updateSettings({ globalEnabled: false })
|
||
},
|
||
resumeAll: async () => {
|
||
await api.updateSettings({ globalEnabled: true })
|
||
},
|
||
quit: async () => undefined,
|
||
reminderClose: async () => undefined,
|
||
minimizeMain: () => undefined,
|
||
toggleMaximizeMain: () => undefined,
|
||
isMaximizedMain: async () => false,
|
||
closeMain: () => undefined,
|
||
hideMain: () => undefined,
|
||
listGames: async () => structuredClone(games),
|
||
installGame: async (id: GameId) => {
|
||
games = games.map((game) =>
|
||
game.id === id
|
||
? {
|
||
...game,
|
||
enabled: true,
|
||
integrationActive: true,
|
||
launchOptionStatus: 'applied'
|
||
}
|
||
: game
|
||
)
|
||
emitGames()
|
||
return structuredClone(games.find((game) => game.id === id)!)
|
||
},
|
||
uninstallGame: async (id: GameId) => {
|
||
games = games.map((game) =>
|
||
game.id === id
|
||
? { ...game, enabled: false, integrationActive: false }
|
||
: game
|
||
)
|
||
emitGames()
|
||
return structuredClone(games.find((game) => game.id === id)!)
|
||
},
|
||
toggleGame: async (id, enabled) => {
|
||
games = games.map((game) =>
|
||
game.id === id ? { ...game, enabled } : game
|
||
)
|
||
state = {
|
||
...state,
|
||
gamesEnabled: { ...state.gamesEnabled, [id]: enabled }
|
||
}
|
||
emitGames()
|
||
emitState()
|
||
},
|
||
openGameLaunchOptions: async () => undefined,
|
||
addChallenge: async (input) => {
|
||
const challenge: Challenge = { ...input, id: nextId('dev-ch') }
|
||
state = { ...state, challenges: [...state.challenges, challenge] }
|
||
emitState()
|
||
return structuredClone(challenge)
|
||
},
|
||
updateChallenge: async (id, patch) => {
|
||
let updated = state.challenges.find((challenge) => challenge.id === id)
|
||
if (!updated) throw new Error(`Unknown challenge ${id}`)
|
||
state = {
|
||
...state,
|
||
challenges: state.challenges.map((challenge) => {
|
||
if (challenge.id !== id) return challenge
|
||
updated = { ...challenge, ...patch, id }
|
||
return updated
|
||
})
|
||
}
|
||
emitState()
|
||
return structuredClone(updated)
|
||
},
|
||
deleteChallenge: async (id) => {
|
||
const before = state.challenges.length
|
||
state = {
|
||
...state,
|
||
challenges: state.challenges.filter((challenge) => challenge.id !== id)
|
||
}
|
||
emitState()
|
||
return state.challenges.length !== before
|
||
},
|
||
toggleChallenge: async (id, enabled) => {
|
||
return api.updateChallenge(id, { enabled })
|
||
},
|
||
markChallengeDone: async (id, reps) => {
|
||
const challenge = state.challenges.find((item) => item.id === id)
|
||
pushHistory({
|
||
ts: Date.now(),
|
||
exerciseId: `challenge:${id}`,
|
||
action: 'done',
|
||
actualReps: reps,
|
||
reps,
|
||
name: challenge?.exerciseName ?? challenge?.name,
|
||
source: 'match'
|
||
})
|
||
return true
|
||
},
|
||
closeMatchSummary: async () => undefined,
|
||
simulateMatchEnd: async () => undefined,
|
||
updaterStatus: async () => updaterStatus,
|
||
updaterCheck: async () => {
|
||
updaterStatus = {
|
||
kind: 'not-available',
|
||
currentVersion: '0.6.5',
|
||
lastCheckedAt: Date.now()
|
||
}
|
||
emitUpdater()
|
||
return updaterStatus
|
||
},
|
||
updaterDownload: () => undefined,
|
||
updaterInstall: () => undefined,
|
||
getHistory: async (sinceMs) =>
|
||
structuredClone(
|
||
sinceMs === undefined
|
||
? history
|
||
: history.filter((entry) => entry.ts >= sinceMs)
|
||
),
|
||
clearHistory: async (beforeTs) => {
|
||
const before = history.length
|
||
history =
|
||
beforeTs === undefined
|
||
? history
|
||
: history.filter((entry) => entry.ts >= beforeTs)
|
||
emitHistory()
|
||
return before - history.length
|
||
},
|
||
exportState: async () => ({
|
||
ok: true,
|
||
canceled: false,
|
||
path: 'C:\\Users\\Demo\\Desktop\\razomnis-backup.json'
|
||
}),
|
||
importState: async () => ({ ok: true, canceled: false }),
|
||
onTick: (handler) => subscribe(tickHandlers, handler),
|
||
onFire: () => emptyUnsub,
|
||
onFireMeal: () => emptyUnsub,
|
||
onMatchEnd: () => emptyUnsub,
|
||
onStateChanged: (handler) => subscribe(stateHandlers, handler),
|
||
onThemeChanged: (handler) => subscribe(themeHandlers, handler),
|
||
onAccentChanged: () => emptyUnsub,
|
||
onGamesChanged: (handler) => subscribe(gamesHandlers, handler),
|
||
onUpdaterStatus: (handler) => subscribe(updaterHandlers, handler),
|
||
onMaximizeChanged: () => emptyUnsub,
|
||
onMeetingChanged: () => emptyUnsub,
|
||
onHistoryChanged: (handler) => subscribe(historyHandlers, handler)
|
||
}
|
||
|
||
window.api = api
|
||
tickTimer = window.setInterval(() => {
|
||
const ticks = buildTicks()
|
||
tickHandlers.forEach((handler) => handler(ticks))
|
||
}, 1000)
|
||
}
|
||
|
||
function diagnostics(): DiagnosticsInfo {
|
||
return {
|
||
generatedAt: Date.now(),
|
||
app: {
|
||
version: '0.6.5',
|
||
isPackaged: false,
|
||
platform: 'win32',
|
||
arch: 'x64'
|
||
},
|
||
runtime: {
|
||
electron: 'dev',
|
||
chrome: 'dev',
|
||
node: 'dev'
|
||
},
|
||
paths: {
|
||
userData: 'dev-renderer',
|
||
store: 'dev-renderer',
|
||
logs: 'dev-renderer'
|
||
},
|
||
store: {
|
||
bytes: null,
|
||
exercises: state.exercises.length,
|
||
meals: state.meals.length,
|
||
challenges: state.challenges.length,
|
||
history: history.length
|
||
},
|
||
updater: updaterStatus,
|
||
games,
|
||
gsi: {
|
||
running: games.some((game) => game.integrationActive),
|
||
port: 38087,
|
||
baseUrl: 'http://127.0.0.1:38087'
|
||
},
|
||
meetingActive: false
|
||
}
|
||
}
|