P1 #4 — ConfirmModal (новый src/renderer/src/components/ui/ConfirmModal.tsx) с iOS-стилем + focus-trap (через Modal). Delete упражнения в Dashboard теперь спрашивает «Удалить упражнение?» с destructive-кнопкой. P1 #5 — Daily goal closed UI. ExerciseCard принимает doneToday prop и при `done >= dailyGoal` показывает «Цель закрыта · 100/100» вместо запутанного «25ч 13м» countdown'а. Цвет — success-зелёный. P1 #6 — Meeting auto-pause indicator. Новый IPC.getMeetingActive + evtMeetingChanged event. meeting-detect broadcast'ит изменения состояния. Dashboard показывает info-баннер «Не дёргаем — ты на встрече» когда meetingAutoPause включён и хотя бы один meeting процесс запущен. P1 #7 — Native window.confirm() заменён на ConfirmModal в Settings DataCard для restore-операции. Теперь iOS-style с destructive confirm-кнопкой и focus-trap'ом. Заодно P2 #8: Brain-иконка-badge на ExerciseCard для adaptive упражнений — пользователь видит почему «Next» не строго равен intervalMinutes. P2 #12: dailyReps/dailyRepsRange/totalDoneReps/repsDoneTodayForExercise используют entry.reps как fallback — heatmap не теряет данные после удаления упражнения.
159 lines
5.3 KiB
TypeScript
159 lines
5.3 KiB
TypeScript
import type { Exercise, HistoryEntry } from '@shared/types'
|
||
|
||
/** YYYY-MM-DD in local time. */
|
||
export function dayKey(ts: number): string {
|
||
const d = new Date(ts)
|
||
const y = d.getFullYear()
|
||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||
const day = String(d.getDate()).padStart(2, '0')
|
||
return `${y}-${m}-${day}`
|
||
}
|
||
|
||
/** Today's local midnight. */
|
||
export function todayKey(): string {
|
||
return dayKey(Date.now())
|
||
}
|
||
|
||
/**
|
||
* Return a new Date offset by `dayDelta` calendar days from `base`, with the
|
||
* time-of-day preserved. Uses `setDate` (calendar arithmetic) rather than
|
||
* subtracting `n * 24h` of milliseconds — across DST transitions ms arithmetic
|
||
* drifts by ±1h and `dayKey` can emit the wrong day.
|
||
*/
|
||
function shiftDays(base: Date, dayDelta: number): Date {
|
||
const d = new Date(base.getTime())
|
||
d.setDate(d.getDate() + dayDelta)
|
||
return d
|
||
}
|
||
|
||
/**
|
||
* Reps logged on a given local day. Uses `actualReps` if present, otherwise
|
||
* looks up exercise's planned `reps`.
|
||
*/
|
||
/**
|
||
* Сколько reps пользователь сделал в заданный day-key. Источники в порядке
|
||
* приоритета:
|
||
* 1. entry.actualReps — что фактически сделал (stepper в reminder'е)
|
||
* 2. entry.reps — snapshot planned-reps на момент записи (выживает после
|
||
* удаления упражнения и работает для match-челленджей у которых нет
|
||
* связанного Exercise)
|
||
* 3. byId.get(exerciseId).reps — fallback для старых entries без snapshot'а
|
||
*/
|
||
/**
|
||
* Сколько reps конкретное упражнение принесло за сегодня. Учитываем как
|
||
* обычные «по таймеру», так и match-челленджи (если их exerciseId совпадает,
|
||
* чего обычно нет; но fallback не помешает).
|
||
*/
|
||
export function repsDoneTodayForExercise(
|
||
entries: HistoryEntry[],
|
||
exercise: Exercise
|
||
): number {
|
||
const today = todayKey()
|
||
let sum = 0
|
||
for (const e of entries) {
|
||
if (e.action !== 'done') continue
|
||
if (e.exerciseId !== exercise.id) continue
|
||
if (dayKey(e.ts) !== today) continue
|
||
sum += e.actualReps ?? e.reps ?? exercise.reps
|
||
}
|
||
return sum
|
||
}
|
||
|
||
export function dailyReps(
|
||
entries: HistoryEntry[],
|
||
exercises: Exercise[],
|
||
dayKeyStr: string
|
||
): number {
|
||
const byId = new Map(exercises.map((e) => [e.id, e]))
|
||
let sum = 0
|
||
for (const e of entries) {
|
||
if (e.action !== 'done') continue
|
||
if (dayKey(e.ts) !== dayKeyStr) continue
|
||
sum += e.actualReps ?? e.reps ?? byId.get(e.exerciseId)?.reps ?? 0
|
||
}
|
||
return sum
|
||
}
|
||
|
||
/**
|
||
* Map of `dayKey → totalReps` for the last `days` days (most recent last).
|
||
* Missing days are still included with value 0.
|
||
*/
|
||
export function dailyRepsRange(
|
||
entries: HistoryEntry[],
|
||
exercises: Exercise[],
|
||
days: number
|
||
): { key: string; date: Date; reps: number }[] {
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
const buckets = new Map<string, { date: Date; reps: number }>()
|
||
const byId = new Map(exercises.map((e) => [e.id, e]))
|
||
|
||
// Seed all days with 0 so heatmap renders contiguous. Use calendar arithmetic
|
||
// (setDate) — DST transitions would shift epoch-based math by ±1h, causing
|
||
// dayKey() to emit duplicate or missing days at the boundary.
|
||
for (let i = days - 1; i >= 0; i--) {
|
||
const d = shiftDays(today, -i)
|
||
buckets.set(dayKey(d.getTime()), { date: d, reps: 0 })
|
||
}
|
||
|
||
for (const e of entries) {
|
||
if (e.action !== 'done') continue
|
||
const k = dayKey(e.ts)
|
||
const bucket = buckets.get(k)
|
||
if (!bucket) continue
|
||
const reps = e.actualReps ?? e.reps ?? byId.get(e.exerciseId)?.reps ?? 0
|
||
bucket.reps += reps
|
||
}
|
||
|
||
return Array.from(buckets, ([key, { date, reps }]) => ({ key, date, reps }))
|
||
}
|
||
|
||
/**
|
||
* Current streak: consecutive days ending today (or yesterday — grace day)
|
||
* where at least one `done` was logged. Returns 0 if neither today nor
|
||
* yesterday has any done activity.
|
||
*/
|
||
export function currentStreak(entries: HistoryEntry[]): number {
|
||
const doneDays = new Set<string>()
|
||
for (const e of entries) {
|
||
if (e.action === 'done') doneDays.add(dayKey(e.ts))
|
||
}
|
||
if (doneDays.size === 0) return 0
|
||
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
const yesterday = shiftDays(today, -1)
|
||
const todayK = dayKey(today.getTime())
|
||
const yesterdayK = dayKey(yesterday.getTime())
|
||
|
||
// Start from today if active today, else yesterday (grace), else 0.
|
||
let cursor: Date | null = doneDays.has(todayK)
|
||
? today
|
||
: doneDays.has(yesterdayK)
|
||
? yesterday
|
||
: null
|
||
if (!cursor) return 0
|
||
|
||
let streak = 0
|
||
while (doneDays.has(dayKey(cursor.getTime()))) {
|
||
streak++
|
||
cursor = shiftDays(cursor, -1)
|
||
}
|
||
return streak
|
||
}
|
||
|
||
/** Total scheduled reps across all enabled exercises today (planned target). */
|
||
export function plannedRepsToday(exercises: Exercise[]): number {
|
||
// For now, "planned today" = sum of enabled exercises' reps × times per day
|
||
// approximation. A more honest target would count expected fires before
|
||
// midnight. We use a simple proxy: reps per exercise weighted by how often
|
||
// it'd fire in a day (1440 min / intervalMinutes).
|
||
let sum = 0
|
||
for (const e of exercises) {
|
||
if (!e.enabled) continue
|
||
const firesPerDay = Math.max(1, Math.floor(1440 / e.intervalMinutes))
|
||
sum += e.reps * firesPerDay
|
||
}
|
||
return sum
|
||
}
|