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 { fireReminder } from './notifications'
|
||||||
import { broadcastState } from './state-actions'
|
import { broadcastState } from './state-actions'
|
||||||
import { isMeetingActiveSync, refreshMeetingState } from './meeting-detect'
|
import { isMeetingActiveSync, refreshMeetingState } from './meeting-detect'
|
||||||
|
import { adjustNextFireAt } from './adaptive'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day).
|
* Сколько reps пользователь сделал по упражнению `ex` за сегодня (local day).
|
||||||
@@ -57,10 +58,12 @@ function checkDueExercises(): void {
|
|||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const exercises = getExercises()
|
const exercises = getExercises()
|
||||||
// history запрашивается только если хотя бы у одного упражнения есть
|
// history запрашивается если у какого-нибудь упражнения есть
|
||||||
// dailyGoal — для большинства pure-interval упражнений не нужна.
|
// dailyGoal или adaptive: false — иначе экономим IPC-нагрузку.
|
||||||
const anyGoal = exercises.some((e) => e.dailyGoal !== undefined)
|
const needsHistory = exercises.some(
|
||||||
const history = anyGoal ? getHistory() : []
|
(e) => e.dailyGoal !== undefined || e.adaptive
|
||||||
|
)
|
||||||
|
const history = needsHistory ? getHistory() : []
|
||||||
let anyFired = false
|
let anyFired = false
|
||||||
for (const ex of exercises) {
|
for (const ex of exercises) {
|
||||||
if (!ex.enabled) continue
|
if (!ex.enabled) continue
|
||||||
@@ -77,9 +80,13 @@ function checkDueExercises(): void {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const updated = updateExercise(ex.id, {
|
// Базовый candidate. Если adaptive — сдвигаем на «хороший» час
|
||||||
nextFireAt: now + ex.intervalMinutes * 60_000
|
// по исторической статистике успеха/скипов.
|
||||||
})
|
let nextFireAt = now + ex.intervalMinutes * 60_000
|
||||||
|
if (ex.adaptive) {
|
||||||
|
nextFireAt = adjustNextFireAt(ex, nextFireAt, history)
|
||||||
|
}
|
||||||
|
const updated = updateExercise(ex.id, { nextFireAt })
|
||||||
if (updated) {
|
if (updated) {
|
||||||
anyFired = true
|
anyFired = true
|
||||||
fireReminder(updated, settings.notificationMode)
|
fireReminder(updated, settings.notificationMode)
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ export function validateExerciseInput(
|
|||||||
}
|
}
|
||||||
if (category !== undefined) out.category = category
|
if (category !== undefined) out.category = category
|
||||||
if (dailyGoal !== undefined) out.dailyGoal = dailyGoal
|
if (dailyGoal !== undefined) out.dailyGoal = dailyGoal
|
||||||
|
const adaptive = bool(raw.adaptive)
|
||||||
|
if (adaptive !== undefined) out.adaptive = adaptive
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +168,11 @@ export function validateExercisePatch(
|
|||||||
out.dailyGoal = v
|
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
|
// Allow scheduler-controlled fields to be patched (used by store.markDone
|
||||||
// through this same boundary), but range-check them.
|
// through this same boundary), but range-check them.
|
||||||
if ('nextFireAt' in raw) {
|
if ('nextFireAt' in raw) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Draft = {
|
|||||||
category: ReminderCategory
|
category: ReminderCategory
|
||||||
/** undefined = без дневной цели (только interval). */
|
/** undefined = без дневной цели (только interval). */
|
||||||
dailyGoal?: number
|
dailyGoal?: number
|
||||||
|
adaptive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: Draft = {
|
const EMPTY: Draft = {
|
||||||
@@ -24,7 +25,8 @@ const EMPTY: Draft = {
|
|||||||
intervalMinutes: 30,
|
intervalMinutes: 30,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
category: 'exercise',
|
category: 'exercise',
|
||||||
dailyGoal: undefined
|
dailyGoal: undefined,
|
||||||
|
adaptive: false
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -52,7 +54,8 @@ export function ExerciseEditor({
|
|||||||
intervalMinutes: exercise.intervalMinutes,
|
intervalMinutes: exercise.intervalMinutes,
|
||||||
enabled: exercise.enabled,
|
enabled: exercise.enabled,
|
||||||
category: exercise.category ?? 'exercise',
|
category: exercise.category ?? 'exercise',
|
||||||
dailyGoal: exercise.dailyGoal
|
dailyGoal: exercise.dailyGoal,
|
||||||
|
adaptive: exercise.adaptive ?? false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setDraft(EMPTY)
|
setDraft(EMPTY)
|
||||||
@@ -193,6 +196,25 @@ export function ExerciseEditor({
|
|||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.adaptive ?? false}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, adaptive: e.target.checked })
|
||||||
|
}
|
||||||
|
className="mt-0.5 w-4 h-4 accent-accent"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-[14px] font-semibold leading-tight">
|
||||||
|
{t('editor.field.adaptive.label')}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-text/55 mt-1 leading-snug">
|
||||||
|
{t('editor.field.adaptive.hint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<Field label={t('editor.field.icon')}>
|
<Field label={t('editor.field.icon')}>
|
||||||
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
|
<div className="grid grid-cols-8 gap-2 max-h-44 overflow-y-auto p-2 rounded-2xl bg-surface-2">
|
||||||
{ICON_CHOICES.map((name) => (
|
{ICON_CHOICES.map((name) => (
|
||||||
|
|||||||
@@ -258,6 +258,9 @@ export const ru: Dict = {
|
|||||||
'editor.field.daily_goal.clear': 'Снять',
|
'editor.field.daily_goal.clear': 'Снять',
|
||||||
'editor.field.daily_goal.hint':
|
'editor.field.daily_goal.hint':
|
||||||
'Когда наберёшь столько повторений за день, напоминания этого упражнения умолкнут до завтра.',
|
'Когда наберёшь столько повторений за день, напоминания этого упражнения умолкнут до завтра.',
|
||||||
|
'editor.field.adaptive.label': 'Адаптивное расписание',
|
||||||
|
'editor.field.adaptive.hint':
|
||||||
|
'Шедулер изучает, в какие часы ты чаще делаешь упражнение, и сдвигает напоминания на удобное тебе время. Заработает после 10 событий в истории.',
|
||||||
|
|
||||||
// Reminder window
|
// Reminder window
|
||||||
'reminder.kicker': 'Время тренировки',
|
'reminder.kicker': 'Время тренировки',
|
||||||
@@ -563,6 +566,9 @@ export const en: Dict = {
|
|||||||
'editor.field.daily_goal.clear': 'Clear',
|
'editor.field.daily_goal.clear': 'Clear',
|
||||||
'editor.field.daily_goal.hint':
|
'editor.field.daily_goal.hint':
|
||||||
'Once you hit this many reps in a day, this reminder goes quiet until tomorrow.',
|
'Once you hit this many reps in a day, this reminder goes quiet until tomorrow.',
|
||||||
|
'editor.field.adaptive.label': 'Adaptive scheduling',
|
||||||
|
'editor.field.adaptive.hint':
|
||||||
|
'Scheduler learns which hours you reliably do this exercise and shifts reminders into your good windows. Kicks in after 10 history events.',
|
||||||
|
|
||||||
// Reminder window
|
// Reminder window
|
||||||
'reminder.kicker': 'Workout time',
|
'reminder.kicker': 'Workout time',
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export default function Dashboard(): JSX.Element {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
category: import('@shared/types').ReminderCategory
|
category: import('@shared/types').ReminderCategory
|
||||||
dailyGoal?: number
|
dailyGoal?: number
|
||||||
|
adaptive?: boolean
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (editing) await window.api.updateExercise(editing.id, draft)
|
if (editing) await window.api.updateExercise(editing.id, draft)
|
||||||
else await window.api.addExercise(draft)
|
else await window.api.addExercise(draft)
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export type Exercise = {
|
|||||||
* fires когда цель закрыта. Завтра счётчик обнуляется (по local day).
|
* fires когда цель закрыта. Завтра счётчик обнуляется (по local day).
|
||||||
*/
|
*/
|
||||||
dailyGoal?: number
|
dailyGoal?: number
|
||||||
|
/**
|
||||||
|
* Адаптивный режим: scheduler анализирует исторические success/skip
|
||||||
|
* паттерны по часам и сдвигает fire'ы на «хорошие» часы. Не меняет
|
||||||
|
* базовый интервал — корректирует только timestamps.
|
||||||
|
*/
|
||||||
|
adaptive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationMode = 'toast' | 'modal' | 'both'
|
export type NotificationMode = 'toast' | 'modal' | 'both'
|
||||||
|
|||||||
Reference in New Issue
Block a user