feat(v0.5.0): history + streak + heatmap, quiet hours, partial reps, README
== История и стрики (#1) == - HistoryEntry { ts, exerciseId, action: done|skip|snooze, actualReps? } персистится в app-state.json, лимит 10k записей (~3 года), trim oldest 10% - markDone/snooze/skip пишут в историю; markDone принимает optional actualReps - IPC: getHistory(sinceMs?), clearHistory(beforeTs?) + preload bindings - Renderer helpers (src/renderer/src/lib/history.ts): * dayKey(ts) — YYYY-MM-DD local * dailyReps(entries, exs, dayKey) — суммирует actualReps || planned * dailyRepsRange(entries, exs, days) — для heatmap, заполняет gaps нулями * currentStreak(entries) — consecutive days, today или yesterday (grace) - Dashboard теперь 4 hero-карточки: Today (повторов за день) / Streak (дней подряд) / Next / Tracking - Новый компонент HistoryHeatmap — GitHub-style 12-недельный календарь с 5 интенсивностями, локализованными подписями дней/месяцев == Тихие часы (#2) == - shared/types.ts: QuietHours { enabled, from, to, days[] } + isQuietAt() helper с правильной обработкой wrap-around окон (22:00→08:00) - DEFAULT_SETTINGS.quietHours = disabled, 22:00→08:00, все дни - main/scheduler.ts: проверка isQuietAt перед fire; deferred fires поднимаются после окончания окна - Settings UI: новая секция "Тихие часы" с toggle, time-pickers, day-of-week pills == Сделал частично (#3) == - ReminderApp: stepper [−][число][+] вокруг счётчика повторов - При adjusted (actualReps !== exercise.reps) число подсвечивается accent и появляется подпись "Засчитаем X из Y" - markDone передаёт actualReps только если юзер реально изменил — иначе undefined чтобы история фиксировала планируемое значение чисто == README.md (#4) == - Описание, фичи, скриншоты (TODO-плейсхолдер), установка, dev-команды, архитектура, тесты, stack, ссылка на RELEASING.md - Бэйджи version / tests / platform == i18n == - ~14 новых ключей × 2 языка: dashboard.stat.today_done, streak, settings.quiet.* (3 row'а), reminder.partial == Тесты — 51 (было 33) == - shared/quiet-hours.test.ts (5): disabled, same-day, wrap-around, day filtering, zero-length - renderer/lib/history.test.ts (13): dayKey, dailyReps (planned vs actual vs ignore non-done), currentStreak (empty, today gap, consecutive, yesterday grace, multi-entry same day), dailyRepsRange Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
119
src/renderer/src/lib/history.ts
Normal file
119
src/renderer/src/lib/history.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { Exercise, HistoryEntry } from '@shared/types'
|
||||
|
||||
const MS_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
/** 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())
|
||||
}
|
||||
|
||||
/**
|
||||
* Reps logged on a given local day. Uses `actualReps` if present, otherwise
|
||||
* looks up exercise's planned `reps`.
|
||||
*/
|
||||
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 ?? 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, number>()
|
||||
const byId = new Map(exercises.map((e) => [e.id, e]))
|
||||
|
||||
// Seed all days with 0 so heatmap renders contiguous.
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today.getTime() - i * MS_DAY)
|
||||
buckets.set(dayKey(d.getTime()), 0)
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
if (e.action !== 'done') continue
|
||||
const k = dayKey(e.ts)
|
||||
if (!buckets.has(k)) continue
|
||||
const reps = e.actualReps ?? byId.get(e.exerciseId)?.reps ?? 0
|
||||
buckets.set(k, (buckets.get(k) ?? 0) + reps)
|
||||
}
|
||||
|
||||
return Array.from(buckets, ([key, reps]) => ({
|
||||
key,
|
||||
date: new Date(`${key}T00:00:00`),
|
||||
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 todayK = dayKey(today.getTime())
|
||||
const yesterdayK = dayKey(today.getTime() - MS_DAY)
|
||||
|
||||
// Start from today if active today, else yesterday (grace), else 0.
|
||||
let cursor = doneDays.has(todayK)
|
||||
? today
|
||||
: doneDays.has(yesterdayK)
|
||||
? new Date(today.getTime() - MS_DAY)
|
||||
: null
|
||||
if (!cursor) return 0
|
||||
|
||||
let streak = 0
|
||||
while (doneDays.has(dayKey(cursor.getTime()))) {
|
||||
streak++
|
||||
cursor = new Date(cursor.getTime() - MS_DAY)
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user