feat(#2): адаптивный шедулер — сдвигает напоминания на «хорошие» часы из истории
This commit is contained in:
94
src/main/adaptive.ts
Normal file
94
src/main/adaptive.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Адаптивный шедулер v1: смотрит на исторические success/skip-паттерны
|
||||
* по часам дня и сдвигает следующий fire на ближайший «хороший» час.
|
||||
*
|
||||
* Без ML, чистая heuristic. Идея: каждому часу 0..23 присваиваем
|
||||
* success-rate = done / (done + skip + snooze). Если интервал шедулера
|
||||
* приходится на «плохой» час (success ≤ 30%), сдвигаем fire вперёд на
|
||||
* ближайший «хороший» (≥ 50% success или не имеющий данных = neutral).
|
||||
*
|
||||
* Окно учитываемой истории: 30 дней. Требует минимум 10 событий по
|
||||
* упражнению, иначе истории слишком мало для статистики — возвращаем
|
||||
* candidate без изменений.
|
||||
*
|
||||
* Этот модуль НЕ обязателен для работы шедулера; вызывается опционально
|
||||
* когда у упражнения `adaptive: true`.
|
||||
*/
|
||||
import type { Exercise, HistoryEntry } from '@shared/types'
|
||||
|
||||
const LOOKBACK_MS = 30 * 24 * 60 * 60 * 1000 // 30 дней
|
||||
const MIN_EVENTS_FOR_TRUST = 10
|
||||
const BAD_HOUR_THRESHOLD = 0.3
|
||||
const GOOD_HOUR_THRESHOLD = 0.5
|
||||
/** Максимальный сдвиг — чтобы не утащить fire на 6 часов вперёд. */
|
||||
const MAX_SHIFT_HOURS = 4
|
||||
|
||||
type HourStats = {
|
||||
done: number
|
||||
skipped: number
|
||||
total: number
|
||||
}
|
||||
|
||||
function buildHourStats(
|
||||
exerciseId: string,
|
||||
history: HistoryEntry[]
|
||||
): HourStats[] {
|
||||
const cutoff = Date.now() - LOOKBACK_MS
|
||||
const stats: HourStats[] = Array.from({ length: 24 }, () => ({
|
||||
done: 0,
|
||||
skipped: 0,
|
||||
total: 0
|
||||
}))
|
||||
for (const e of history) {
|
||||
if (e.exerciseId !== exerciseId) continue
|
||||
if (e.ts < cutoff) continue
|
||||
const h = new Date(e.ts).getHours()
|
||||
stats[h].total++
|
||||
if (e.action === 'done') stats[h].done++
|
||||
else stats[h].skipped++ // skip + snooze оба считаются «не done»
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
function isHourGood(s: HourStats): boolean {
|
||||
if (s.total === 0) return true // нет данных — нейтрально, ОК
|
||||
return s.done / s.total >= GOOD_HOUR_THRESHOLD
|
||||
}
|
||||
|
||||
function isHourBad(s: HourStats): boolean {
|
||||
if (s.total < 3) return false // данных мало, не делаем выводы
|
||||
return s.done / s.total <= BAD_HOUR_THRESHOLD
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает скорректированный timestamp следующего fire. Если candidate
|
||||
* попадает на «плохой» час и есть «хороший» час в пределах MAX_SHIFT_HOURS,
|
||||
* сдвигаем на начало этого хорошего часа. Иначе возвращаем candidate
|
||||
* как есть.
|
||||
*/
|
||||
export function adjustNextFireAt(
|
||||
exercise: Exercise,
|
||||
candidateTs: number,
|
||||
history: HistoryEntry[]
|
||||
): number {
|
||||
// Сначала собираем total — если истории мало, не двигаем.
|
||||
const stats = buildHourStats(exercise.id, history)
|
||||
const total = stats.reduce((s, h) => s + h.total, 0)
|
||||
if (total < MIN_EVENTS_FOR_TRUST) return candidateTs
|
||||
|
||||
const candDate = new Date(candidateTs)
|
||||
const candHour = candDate.getHours()
|
||||
if (!isHourBad(stats[candHour])) return candidateTs
|
||||
|
||||
// Ищем ближайший «хороший» час в будущем (внутри MAX_SHIFT_HOURS).
|
||||
for (let shift = 1; shift <= MAX_SHIFT_HOURS; shift++) {
|
||||
const h = (candHour + shift) % 24
|
||||
if (isHourGood(stats[h])) {
|
||||
const target = new Date(candDate)
|
||||
target.setHours(candHour + shift, 0, 0, 0)
|
||||
return target.getTime()
|
||||
}
|
||||
}
|
||||
// Не нашли — оставляем как есть.
|
||||
return candidateTs
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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).
|
||||
@@ -57,10 +58,12 @@ function checkDueExercises(): void {
|
||||
|
||||
const now = Date.now()
|
||||
const exercises = getExercises()
|
||||
// history запрашивается только если хотя бы у одного упражнения есть
|
||||
// dailyGoal — для большинства pure-interval упражнений не нужна.
|
||||
const anyGoal = exercises.some((e) => e.dailyGoal !== undefined)
|
||||
const history = anyGoal ? getHistory() : []
|
||||
// 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
|
||||
@@ -77,9 +80,13 @@ function checkDueExercises(): void {
|
||||
continue
|
||||
}
|
||||
}
|
||||
const updated = updateExercise(ex.id, {
|
||||
nextFireAt: now + ex.intervalMinutes * 60_000
|
||||
})
|
||||
// Базовый 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)
|
||||
|
||||
@@ -118,6 +118,8 @@ export function validateExerciseInput(
|
||||
}
|
||||
if (category !== undefined) out.category = category
|
||||
if (dailyGoal !== undefined) out.dailyGoal = dailyGoal
|
||||
const adaptive = bool(raw.adaptive)
|
||||
if (adaptive !== undefined) out.adaptive = adaptive
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -166,6 +168,11 @@ export function validateExercisePatch(
|
||||
out.dailyGoal = v
|
||||
}
|
||||
}
|
||||
if ('adaptive' in raw) {
|
||||
const v = bool(raw.adaptive)
|
||||
if (v === undefined) return null
|
||||
out.adaptive = v
|
||||
}
|
||||
// Allow scheduler-controlled fields to be patched (used by store.markDone
|
||||
// through this same boundary), but range-check them.
|
||||
if ('nextFireAt' in raw) {
|
||||
|
||||
Reference in New Issue
Block a user