feat(#2): адаптивный шедулер — сдвигает напоминания на «хорошие» часы из истории

This commit is contained in:
AnRil
2026-05-22 13:52:38 +07:00
parent 81481f2131
commit a0b89ddf71
7 changed files with 152 additions and 9 deletions

94
src/main/adaptive.ts Normal file
View 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
}