import { powerMonitor, BrowserWindow } from 'electron' import { IPC } from '@shared/ipc' import type { Exercise, Tick, HistoryEntry } from '@shared/types' import { isQuietAt } from '@shared/types' import { getExercises, getHistory, getSettings, updateExercise } from './store' import { fireReminder } from './notifications' import { broadcastState } from './state-actions' import { isMeetingActiveSync, refreshMeetingState } from './meeting-detect' import { adjustNextFireAt } from './adaptive' /** * Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day). * Учитываем actualReps если задано (частичное выполнение), иначе planned reps. */ function repsDoneToday(ex: Exercise, history: HistoryEntry[]): number { const todayKey = new Date() todayKey.setHours(0, 0, 0, 0) const startMs = todayKey.getTime() let sum = 0 for (const e of history) { if (e.action !== 'done') continue if (e.exerciseId !== ex.id) continue if (e.ts < startMs) continue sum += e.actualReps ?? ex.reps } return sum } /** * TICK_MS drives the per-second countdown UI; CHECK_MS gates the (cheaper) * "is anything due to fire?" pass so we don't iterate exercises every second. */ const TICK_MS = 1000 const CHECK_MS = 5000 let tickHandle: NodeJS.Timeout | null = null let powerListenersArmed = false let lastCheckAt = 0 let paused = false function checkDueExercises(): void { if (paused) return const settings = getSettings() if (!settings.globalEnabled) return // Inside the quiet window: defer all due fires until it closes. The next // CHECK_MS pass after the window ends will pick them up. if (isQuietAt(settings.quietHours, new Date())) return // Авто-пауза на встречах. Sync-чтение кеша (последнее значение); refresh // запускаем в фоне чтобы кеш «зрел» к следующему tick'у. На холодном // старте кеш false — первое срабатывание может прийти в момент митинга, // но 30 сек спустя система догонит и больше не будет fire'ить. if (settings.meetingAutoPause) { refreshMeetingState() if (isMeetingActiveSync()) return } const now = Date.now() const exercises = getExercises() // history запрашивается если у какого-нибудь упражнения есть // dailyGoal или adaptive: false — иначе экономим IPC-нагрузку. const needsHistory = exercises.some( (e) => e.dailyGoal !== undefined || e.adaptive ) const history = needsHistory ? getHistory() : [] let anyFired = false for (const ex of exercises) { if (!ex.enabled) continue if (ex.nextFireAt > now) continue // Soft cap: если dailyGoal задан и уже выполнен — переносим // следующий fire на «начало завтра» (без повторных проверок до утра). if (ex.dailyGoal !== undefined && ex.dailyGoal > 0) { const done = repsDoneToday(ex, history) if (done >= ex.dailyGoal) { const tomorrow = new Date() tomorrow.setHours(0, 0, 0, 0) tomorrow.setDate(tomorrow.getDate() + 1) updateExercise(ex.id, { nextFireAt: tomorrow.getTime() }) continue } } // Базовый candidate. Если adaptive — сдвигаем на «хороший» час // по исторической статистике успеха/скипов. let nextFireAt = now + ex.intervalMinutes * 60_000 if (ex.adaptive) { nextFireAt = adjustNextFireAt(ex, nextFireAt, history) } const updated = updateExercise(ex.id, { nextFireAt }) if (updated) { anyFired = true fireReminder(updated, settings.notificationMode) } } // Push fresh state so the renderer's Dashboard/Exercises pages don't show // stale `nextFireAt` until the next state-changing IPC arrives. if (anyFired) broadcastState() } function broadcastTicks(): void { const now = Date.now() const ticks: Tick[] = getExercises().map((e) => ({ exerciseId: e.id, msUntilFire: Math.max(0, e.nextFireAt - now), enabled: e.enabled })) for (const win of BrowserWindow.getAllWindows()) { if (!win.isDestroyed()) win.webContents.send(IPC.evtTick, ticks) } } function tick(): void { broadcastTicks() const now = Date.now() if (now - lastCheckAt >= CHECK_MS) { lastCheckAt = now checkDueExercises() } } export function startScheduler(): void { if (tickHandle) return lastCheckAt = 0 tickHandle = setInterval(tick, TICK_MS) // Run an immediate tick so renderer hydrates quickly. tick() // Only attach powerMonitor listeners once per process — startScheduler may // be invoked again after stopScheduler in dev hot-reload paths and we don't // want the same handler firing N times after a resume. if (!powerListenersArmed) { powerListenersArmed = true powerMonitor.on('resume', () => { lastCheckAt = 0 tick() }) powerMonitor.on('unlock-screen', () => { lastCheckAt = 0 tick() }) } } export function stopScheduler(): void { if (tickHandle) { clearInterval(tickHandle) tickHandle = null } } export function setPaused(value: boolean): void { paused = value } export function isPaused(): boolean { return paused } export function forceCheck(): void { lastCheckAt = 0 tick() }