/** * Адаптивный шедулер 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 }