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