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:
@@ -8,10 +8,15 @@ import {
|
||||
DEFAULT_SETTINGS,
|
||||
Exercise,
|
||||
GameId,
|
||||
HistoryAction,
|
||||
HistoryEntry,
|
||||
SAMPLE_EXERCISES,
|
||||
Settings
|
||||
} from '@shared/types'
|
||||
|
||||
/** Keep at most this many entries (~3 years if ~10/day). Trim oldest. */
|
||||
const HISTORY_MAX = 10_000
|
||||
|
||||
let cache: AppState | null = null
|
||||
let storePath = ''
|
||||
let pendingWrite: NodeJS.Timeout | null = null
|
||||
@@ -56,7 +61,8 @@ function makeInitial(): AppState {
|
||||
enabled: false
|
||||
}
|
||||
],
|
||||
gamesEnabled: {}
|
||||
gamesEnabled: {},
|
||||
history: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +80,49 @@ function load(): AppState {
|
||||
exercises: parsed.exercises ?? [],
|
||||
settings: { ...DEFAULT_SETTINGS, ...(parsed.settings ?? {}) },
|
||||
challenges: parsed.challenges ?? [],
|
||||
gamesEnabled: parsed.gamesEnabled ?? {}
|
||||
gamesEnabled: parsed.gamesEnabled ?? {},
|
||||
history: parsed.history ?? []
|
||||
}
|
||||
} catch {
|
||||
return makeInitial()
|
||||
}
|
||||
}
|
||||
|
||||
function appendHistory(
|
||||
exerciseId: string,
|
||||
action: HistoryAction,
|
||||
actualReps?: number
|
||||
): void {
|
||||
const state = getState()
|
||||
if (!state.history) state.history = []
|
||||
const entry: HistoryEntry = { ts: Date.now(), exerciseId, action }
|
||||
if (actualReps !== undefined) entry.actualReps = actualReps
|
||||
state.history.push(entry)
|
||||
// Cap size — trim oldest 10% when over limit, so we don't trim every write.
|
||||
if (state.history.length > HISTORY_MAX) {
|
||||
state.history = state.history.slice(-Math.floor(HISTORY_MAX * 0.9))
|
||||
}
|
||||
scheduleWrite()
|
||||
}
|
||||
|
||||
export function getHistory(sinceMs?: number): HistoryEntry[] {
|
||||
const all = getState().history ?? []
|
||||
if (sinceMs == null) return all
|
||||
return all.filter((e) => e.ts >= sinceMs)
|
||||
}
|
||||
|
||||
export function clearHistory(beforeTs?: number): number {
|
||||
const state = getState()
|
||||
const before = state.history?.length ?? 0
|
||||
if (beforeTs == null) {
|
||||
state.history = []
|
||||
} else {
|
||||
state.history = (state.history ?? []).filter((e) => e.ts >= beforeTs)
|
||||
}
|
||||
scheduleWrite()
|
||||
return before - (state.history?.length ?? 0)
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (!cache) return
|
||||
writeFileSync(getStorePath(), JSON.stringify(cache, null, 2), 'utf-8')
|
||||
@@ -155,12 +197,16 @@ export function deleteExercise(id: string): boolean {
|
||||
return changed
|
||||
}
|
||||
|
||||
export function markDone(id: string): Exercise | undefined {
|
||||
export function markDone(
|
||||
id: string,
|
||||
actualReps?: number
|
||||
): Exercise | undefined {
|
||||
const state = getState()
|
||||
const ex = state.exercises.find((e) => e.id === id)
|
||||
if (!ex) return undefined
|
||||
ex.lastDoneAt = Date.now()
|
||||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||
appendHistory(id, 'done', actualReps)
|
||||
scheduleWrite()
|
||||
return ex
|
||||
}
|
||||
@@ -170,6 +216,7 @@ export function snooze(id: string, minutes: number): Exercise | undefined {
|
||||
const ex = state.exercises.find((e) => e.id === id)
|
||||
if (!ex) return undefined
|
||||
ex.nextFireAt = Date.now() + minutes * 60_000
|
||||
appendHistory(id, 'snooze')
|
||||
scheduleWrite()
|
||||
return ex
|
||||
}
|
||||
@@ -179,6 +226,7 @@ export function skip(id: string): Exercise | undefined {
|
||||
const ex = state.exercises.find((e) => e.id === id)
|
||||
if (!ex) return undefined
|
||||
ex.nextFireAt = Date.now() + ex.intervalMinutes * 60_000
|
||||
appendHistory(id, 'skip')
|
||||
scheduleWrite()
|
||||
return ex
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user