feat(v0.5.0): history + streak + heatmap, quiet hours, partial reps, README
Some checks failed
CI / Typecheck + Tests (push) Has been cancelled
CI / Build (Windows) (push) Has been cancelled
Release / Build installer + publish release (push) Has been cancelled

== История и стрики (#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:
AnRil
2026-05-18 12:41:13 +07:00
parent 973339ca62
commit c9d4fc237e
16 changed files with 975 additions and 32 deletions

View 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
}