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
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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) => (

View File

@@ -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',

View File

@@ -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)

View File

@@ -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'