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:
@@ -1,6 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Check, Clock, X, Trophy, Frown, Gamepad2 } from 'lucide-react'
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
X,
|
||||
Trophy,
|
||||
Frown,
|
||||
Gamepad2,
|
||||
Minus,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
Exercise,
|
||||
MatchSummary,
|
||||
@@ -113,8 +122,16 @@ function ExerciseReminder({
|
||||
const t = (key: string, vars?: Record<string, string | number>): string =>
|
||||
translate(lang, key, vars)
|
||||
|
||||
const [actualReps, setActualReps] = useState(exercise.reps)
|
||||
const adjusted = actualReps !== exercise.reps
|
||||
|
||||
async function done(): Promise<void> {
|
||||
await window.api.markDone(exercise.id)
|
||||
// Only pass actualReps when user adjusted — otherwise leave undefined
|
||||
// so history records the full planned value cleanly.
|
||||
await window.api.markDone(
|
||||
exercise.id,
|
||||
adjusted ? actualReps : undefined
|
||||
)
|
||||
onClose()
|
||||
}
|
||||
async function snooze(): Promise<void> {
|
||||
@@ -125,6 +142,8 @@ function ExerciseReminder({
|
||||
await window.api.skip(exercise.id)
|
||||
onClose()
|
||||
}
|
||||
const dec = (): void => setActualReps((n) => Math.max(0, n - 1))
|
||||
const inc = (): void => setActualReps((n) => n + 1)
|
||||
|
||||
return (
|
||||
<div className="reminder-shell flex flex-col h-full">
|
||||
@@ -157,14 +176,41 @@ function ExerciseReminder({
|
||||
{exercise.name}
|
||||
</h1>
|
||||
|
||||
<div className="inline-flex items-baseline gap-2 font-mono-num">
|
||||
<span className="text-[56px] font-semibold tracking-tight text-text leading-none">
|
||||
{exercise.reps}
|
||||
</span>
|
||||
<span className="text-[15px] text-text/65 font-semibold">
|
||||
{t('reminder.reps')}
|
||||
</span>
|
||||
{/* Reps stepper — tap +/− if you did less than planned. */}
|
||||
<div className="inline-flex items-center gap-3 select-none">
|
||||
<button
|
||||
onClick={dec}
|
||||
className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all"
|
||||
aria-label="−"
|
||||
>
|
||||
<Minus size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
<div className="inline-flex items-baseline gap-2 font-mono-num min-w-[120px] justify-center">
|
||||
<span
|
||||
className={[
|
||||
'text-[56px] font-semibold tracking-tight leading-none',
|
||||
adjusted ? 'text-accent' : 'text-text'
|
||||
].join(' ')}
|
||||
>
|
||||
{actualReps}
|
||||
</span>
|
||||
<span className="text-[15px] text-text/65 font-semibold">
|
||||
{t('reminder.reps')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={inc}
|
||||
className="w-9 h-9 grid place-items-center rounded-full bg-surface-2 text-text/65 hover:text-text hover:bg-hairline/25 active:scale-90 transition-all"
|
||||
aria-label="+"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
{adjusted && (
|
||||
<div className="text-[12px] text-accent mt-2 font-medium">
|
||||
{t('reminder.partial', { actual: actualReps, planned: exercise.reps })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-[13px] text-text/65 mt-4 inline-flex items-center gap-1.5 font-medium">
|
||||
<Clock size={12} strokeWidth={2.4} />
|
||||
|
||||
174
src/renderer/src/components/HistoryHeatmap.tsx
Normal file
174
src/renderer/src/components/HistoryHeatmap.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useMemo } from 'react'
|
||||
import { dailyRepsRange } from '../lib/history'
|
||||
import type { Exercise, HistoryEntry, Language } from '@shared/types'
|
||||
|
||||
type Props = {
|
||||
history: HistoryEntry[]
|
||||
exercises: Exercise[]
|
||||
days?: number
|
||||
lang: Language
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub-style contribution grid: weeks as columns, days-of-week as rows.
|
||||
* Intensity bucket from 0 to 4 based on relative reps within the window.
|
||||
*/
|
||||
export function HistoryHeatmap({
|
||||
history,
|
||||
exercises,
|
||||
days = 84, // 12 weeks
|
||||
lang
|
||||
}: Props): JSX.Element {
|
||||
const cells = useMemo(
|
||||
() => dailyRepsRange(history, exercises, days),
|
||||
[history, exercises, days]
|
||||
)
|
||||
const max = cells.reduce((m, c) => Math.max(m, c.reps), 0)
|
||||
|
||||
// Bucket function — 0 for zero, 1-4 for low/med/high/peak.
|
||||
function bucket(n: number): number {
|
||||
if (n === 0) return 0
|
||||
if (max === 0) return 0
|
||||
const ratio = n / max
|
||||
if (ratio < 0.25) return 1
|
||||
if (ratio < 0.5) return 2
|
||||
if (ratio < 0.85) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
// Group cells into columns (weeks). Pad start so first column aligns to
|
||||
// its actual week (Mon-first).
|
||||
const firstDay = cells[0]?.date ?? new Date()
|
||||
const firstWeekday = (firstDay.getDay() + 6) % 7 // 0 = Mon
|
||||
const padded: ({
|
||||
key: string
|
||||
date: Date
|
||||
reps: number
|
||||
} | null)[] = [...Array(firstWeekday).fill(null), ...cells]
|
||||
const weeks: (typeof padded)[] = []
|
||||
for (let i = 0; i < padded.length; i += 7) {
|
||||
weeks.push(padded.slice(i, i + 7))
|
||||
}
|
||||
|
||||
const dayLabels =
|
||||
lang === 'en'
|
||||
? ['Mon', '', 'Wed', '', 'Fri', '', 'Sun']
|
||||
: ['Пн', '', 'Ср', '', 'Пт', '', 'Вс']
|
||||
|
||||
const monthLabels = useMemo(() => {
|
||||
const fmt = new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', {
|
||||
month: 'short'
|
||||
})
|
||||
return weeks.map((w) => {
|
||||
const first = w.find((c) => c !== null)
|
||||
return first ? fmt.format(first.date) : ''
|
||||
})
|
||||
}, [weeks, lang])
|
||||
|
||||
// Compress repeated month labels (only show on first week of the month)
|
||||
const monthLabelsCompressed = monthLabels.map((label, i) =>
|
||||
label && label !== monthLabels[i - 1] ? label : ''
|
||||
)
|
||||
|
||||
const dateFmt = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(lang === 'en' ? 'en-US' : 'ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
}),
|
||||
[lang]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="text-[14px] text-text/75 font-semibold">
|
||||
{lang === 'en' ? 'Activity, last 12 weeks' : 'Активность за 12 недель'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{/* Month labels above grid */}
|
||||
<div className="flex gap-[3px] mb-1 pl-7">
|
||||
{monthLabelsCompressed.map((label, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[12px] text-[10px] text-text/45 font-medium"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-[6px]">
|
||||
<div className="flex flex-col gap-[3px] justify-around pt-0.5">
|
||||
{dayLabels.map((l, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[12px] text-[10px] text-text/40 font-medium leading-none w-5 text-right"
|
||||
>
|
||||
{l}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-[3px]">
|
||||
{weeks.map((w, wi) => (
|
||||
<div key={wi} className="flex flex-col gap-[3px]">
|
||||
{w.map((c, di) => {
|
||||
if (!c) {
|
||||
return (
|
||||
<div key={di} className="w-[12px] h-[12px]" />
|
||||
)
|
||||
}
|
||||
const b = bucket(c.reps)
|
||||
const tone =
|
||||
b === 0
|
||||
? 'bg-surface-2'
|
||||
: b === 1
|
||||
? 'bg-accent/30'
|
||||
: b === 2
|
||||
? 'bg-accent/55'
|
||||
: b === 3
|
||||
? 'bg-accent/80'
|
||||
: 'bg-accent'
|
||||
return (
|
||||
<div
|
||||
key={di}
|
||||
title={`${dateFmt.format(c.date)} · ${c.reps} ${lang === 'en' ? 'reps' : 'повторов'}`}
|
||||
className={[
|
||||
'w-[12px] h-[12px] rounded-[3px] transition-colors',
|
||||
tone
|
||||
].join(' ')}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-end gap-1.5 mt-3 text-[10px] text-text/45 font-medium">
|
||||
<span>{lang === 'en' ? 'Less' : 'Меньше'}</span>
|
||||
{[0, 1, 2, 3, 4].map((b) => (
|
||||
<div
|
||||
key={b}
|
||||
className={[
|
||||
'w-[10px] h-[10px] rounded-[2px]',
|
||||
b === 0
|
||||
? 'bg-surface-2'
|
||||
: b === 1
|
||||
? 'bg-accent/30'
|
||||
: b === 2
|
||||
? 'bg-accent/55'
|
||||
: b === 3
|
||||
? 'bg-accent/80'
|
||||
: 'bg-accent'
|
||||
].join(' ')}
|
||||
/>
|
||||
))}
|
||||
<span>{lang === 'en' ? 'More' : 'Больше'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,10 @@ export const ru: Dict = {
|
||||
'dashboard.title': 'Сегодня',
|
||||
'dashboard.stat.active': 'Активных',
|
||||
'dashboard.stat.active.of': 'из {total}',
|
||||
'dashboard.stat.today_done': 'Сегодня',
|
||||
'dashboard.stat.today_done.subtitle': 'повторов за день',
|
||||
'dashboard.stat.streak': 'Стрик',
|
||||
'dashboard.stat.streak.subtitle': '{n} дн. подряд',
|
||||
'dashboard.stat.next': 'До следующего',
|
||||
'dashboard.stat.next.now': 'Сейчас',
|
||||
'dashboard.stat.next.subtitle_paused': 'на паузе',
|
||||
@@ -133,6 +137,7 @@ export const ru: Dict = {
|
||||
'settings.kicker': 'Конфигурация',
|
||||
'settings.title': 'Настройки',
|
||||
'settings.section.reminders': 'Напоминания',
|
||||
'settings.section.quiet': 'Тихие часы',
|
||||
'settings.section.window': 'Окно и трей',
|
||||
'settings.section.appearance': 'Внешний вид',
|
||||
'settings.section.language': 'Язык',
|
||||
@@ -151,6 +156,12 @@ export const ru: Dict = {
|
||||
'settings.snooze.10': '10 минут',
|
||||
'settings.snooze.15': '15 минут',
|
||||
'settings.snooze.30': '30 минут',
|
||||
'settings.quiet.enabled.label': 'Тихие часы',
|
||||
'settings.quiet.enabled.hint': 'Не показывать напоминания в указанные часы',
|
||||
'settings.quiet.times.label': 'С и до',
|
||||
'settings.quiet.times.hint': 'Если до раньше — окно переходит через полночь',
|
||||
'settings.quiet.days.label': 'Дни недели',
|
||||
'settings.quiet.days.hint': 'Тихие часы действуют в выбранные дни',
|
||||
'settings.tray.label': 'Сворачивать в трей',
|
||||
'settings.tray.hint': 'При закрытии остаётся работать в фоне',
|
||||
'settings.autostart.label': 'Запускать с Windows',
|
||||
@@ -188,6 +199,7 @@ export const ru: Dict = {
|
||||
'reminder.subkicker': 'Двигайся',
|
||||
'reminder.reps': 'раз',
|
||||
'reminder.next_in': 'Следующее через {interval}',
|
||||
'reminder.partial': 'Засчитаем {actual} из {planned}',
|
||||
'reminder.btn.done': 'Готово',
|
||||
'match.title.won': 'Победа',
|
||||
'match.title.lost': 'Поражение',
|
||||
@@ -254,6 +266,10 @@ export const en: Dict = {
|
||||
'dashboard.title': 'Today',
|
||||
'dashboard.stat.active': 'Active',
|
||||
'dashboard.stat.active.of': 'of {total}',
|
||||
'dashboard.stat.today_done': 'Today',
|
||||
'dashboard.stat.today_done.subtitle': 'reps logged',
|
||||
'dashboard.stat.streak': 'Streak',
|
||||
'dashboard.stat.streak.subtitle': '{n} days in a row',
|
||||
'dashboard.stat.next': 'Next in',
|
||||
'dashboard.stat.next.now': 'Now',
|
||||
'dashboard.stat.next.subtitle_paused': 'paused',
|
||||
@@ -334,6 +350,7 @@ export const en: Dict = {
|
||||
'settings.kicker': 'Configuration',
|
||||
'settings.title': 'Settings',
|
||||
'settings.section.reminders': 'Reminders',
|
||||
'settings.section.quiet': 'Quiet hours',
|
||||
'settings.section.window': 'Window & tray',
|
||||
'settings.section.appearance': 'Appearance',
|
||||
'settings.section.language': 'Language',
|
||||
@@ -352,6 +369,12 @@ export const en: Dict = {
|
||||
'settings.snooze.10': '10 minutes',
|
||||
'settings.snooze.15': '15 minutes',
|
||||
'settings.snooze.30': '30 minutes',
|
||||
'settings.quiet.enabled.label': 'Quiet hours',
|
||||
'settings.quiet.enabled.hint': 'Suppress reminders during the chosen window',
|
||||
'settings.quiet.times.label': 'From and to',
|
||||
'settings.quiet.times.hint': 'If `to` is earlier, the window wraps midnight',
|
||||
'settings.quiet.days.label': 'Days of week',
|
||||
'settings.quiet.days.hint': 'Quiet hours apply on the selected days',
|
||||
'settings.tray.label': 'Minimize to tray',
|
||||
'settings.tray.hint': 'Keep running in background when closed',
|
||||
'settings.autostart.label': 'Start with Windows',
|
||||
@@ -389,6 +412,7 @@ export const en: Dict = {
|
||||
'reminder.subkicker': 'Move',
|
||||
'reminder.reps': 'reps',
|
||||
'reminder.next_in': 'Next in {interval}',
|
||||
'reminder.partial': "We'll log {actual} of {planned}",
|
||||
'reminder.btn.done': 'Done',
|
||||
'match.title.won': 'Victory',
|
||||
'match.title.lost': 'Defeat',
|
||||
|
||||
127
src/renderer/src/lib/history.test.ts
Normal file
127
src/renderer/src/lib/history.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Exercise, HistoryEntry } from '@shared/types'
|
||||
import { currentStreak, dailyReps, dayKey, dailyRepsRange } from './history'
|
||||
|
||||
const MS_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
function ex(id: string, reps: number): Exercise {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
reps,
|
||||
icon: 'Activity',
|
||||
intervalMinutes: 30,
|
||||
enabled: true,
|
||||
nextFireAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function entry(
|
||||
exerciseId: string,
|
||||
ts: number,
|
||||
action: 'done' | 'skip' | 'snooze' = 'done',
|
||||
actualReps?: number
|
||||
): HistoryEntry {
|
||||
const e: HistoryEntry = { exerciseId, ts, action }
|
||||
if (actualReps !== undefined) e.actualReps = actualReps
|
||||
return e
|
||||
}
|
||||
|
||||
describe('dayKey', () => {
|
||||
it('returns local YYYY-MM-DD', () => {
|
||||
// Midnight local time is "today" — we cannot pin exact value across
|
||||
// timezones, so just assert the format.
|
||||
expect(dayKey(Date.now())).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dailyReps', () => {
|
||||
const today = Date.now()
|
||||
const exs = [ex('a', 10), ex('b', 5)]
|
||||
|
||||
it('counts planned reps when actualReps absent', () => {
|
||||
const hist = [entry('a', today), entry('b', today)]
|
||||
expect(dailyReps(hist, exs, dayKey(today))).toBe(15)
|
||||
})
|
||||
|
||||
it('counts actualReps when present (partial completion)', () => {
|
||||
const hist = [entry('a', today, 'done', 7)]
|
||||
expect(dailyReps(hist, exs, dayKey(today))).toBe(7)
|
||||
})
|
||||
|
||||
it('ignores skip / snooze entries', () => {
|
||||
const hist = [
|
||||
entry('a', today, 'skip'),
|
||||
entry('a', today, 'snooze'),
|
||||
entry('b', today)
|
||||
]
|
||||
expect(dailyReps(hist, exs, dayKey(today))).toBe(5)
|
||||
})
|
||||
|
||||
it('only counts the requested day', () => {
|
||||
const yesterday = today - MS_DAY
|
||||
const hist = [entry('a', today), entry('a', yesterday)]
|
||||
expect(dailyReps(hist, exs, dayKey(today))).toBe(10)
|
||||
expect(dailyReps(hist, exs, dayKey(yesterday))).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('currentStreak', () => {
|
||||
const today = Date.now()
|
||||
const day = (n: number): number => today - n * MS_DAY
|
||||
|
||||
it('returns 0 for empty history', () => {
|
||||
expect(currentStreak([])).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 if no done in last 2 days', () => {
|
||||
expect(currentStreak([entry('a', day(3))])).toBe(0)
|
||||
})
|
||||
|
||||
it('counts consecutive days ending today', () => {
|
||||
const hist = [
|
||||
entry('a', day(0)),
|
||||
entry('a', day(1)),
|
||||
entry('a', day(2)),
|
||||
entry('a', day(4)) // gap
|
||||
]
|
||||
expect(currentStreak(hist)).toBe(3)
|
||||
})
|
||||
|
||||
it('allows yesterday as grace day if today not done yet', () => {
|
||||
const hist = [entry('a', day(1)), entry('a', day(2))]
|
||||
expect(currentStreak(hist)).toBe(2)
|
||||
})
|
||||
|
||||
it('ignores skip and snooze', () => {
|
||||
const hist = [
|
||||
entry('a', day(0), 'skip'),
|
||||
entry('a', day(1), 'snooze')
|
||||
]
|
||||
expect(currentStreak(hist)).toBe(0)
|
||||
})
|
||||
|
||||
it('multiple entries same day count once', () => {
|
||||
const hist = [
|
||||
entry('a', day(0)),
|
||||
entry('b', day(0)),
|
||||
entry('a', day(1))
|
||||
]
|
||||
expect(currentStreak(hist)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dailyRepsRange', () => {
|
||||
it('always returns exactly `days` entries even if no history', () => {
|
||||
expect(dailyRepsRange([], [], 7)).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('sums reps into correct buckets', () => {
|
||||
const today = Date.now()
|
||||
const exs = [ex('a', 10)]
|
||||
const hist = [entry('a', today), entry('a', today - MS_DAY, 'done', 3)]
|
||||
const range = dailyRepsRange(hist, exs, 7)
|
||||
expect(range.at(-1)?.reps).toBe(10) // today
|
||||
expect(range.at(-2)?.reps).toBe(3) // yesterday, partial
|
||||
})
|
||||
})
|
||||
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
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Plus, Pause, Play, Flame, Activity } from 'lucide-react'
|
||||
import { Plus, Pause, Play, Flame, Activity, TrendingUp } from 'lucide-react'
|
||||
import { useAppStore } from '../store/appStore'
|
||||
import { ExerciseCard } from '../components/ExerciseCard'
|
||||
import { ExerciseEditor } from '../components/ExerciseEditor'
|
||||
import { HistoryHeatmap } from '../components/HistoryHeatmap'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import type { Exercise } from '@shared/types'
|
||||
import type { Exercise, HistoryEntry } from '@shared/types'
|
||||
import { formatCountdown } from '../lib/format'
|
||||
import { useT } from '../i18n'
|
||||
import { currentStreak, dailyReps, todayKey } from '../lib/history'
|
||||
|
||||
export default function Dashboard(): JSX.Element {
|
||||
const state = useAppStore((s) => s.state)
|
||||
@@ -20,6 +22,18 @@ export default function Dashboard(): JSX.Element {
|
||||
const settings = state?.settings
|
||||
const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean)
|
||||
|
||||
// Local history mirror; reloaded whenever app-state changes.
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
useEffect(() => {
|
||||
void window.api.getHistory().then(setHistory)
|
||||
}, [state])
|
||||
|
||||
const todayDone = useMemo(
|
||||
() => dailyReps(history, exercises, todayKey()),
|
||||
[history, exercises]
|
||||
)
|
||||
const streak = useMemo(() => currentStreak(history), [history])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const enabled = exercises.filter((e) => e.enabled)
|
||||
const next = enabled
|
||||
@@ -94,13 +108,20 @@ export default function Dashboard(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-8">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
|
||||
<HeroStat
|
||||
tone="accent"
|
||||
label={t('dashboard.stat.active')}
|
||||
value={`${stats.active}`}
|
||||
subvalue={t('dashboard.stat.active.of', { total: stats.total })}
|
||||
icon={<Activity size={14} strokeWidth={2.6} />}
|
||||
label={t('dashboard.stat.today_done')}
|
||||
value={`${todayDone}`}
|
||||
subvalue={t('dashboard.stat.today_done.subtitle')}
|
||||
icon={<TrendingUp size={14} strokeWidth={2.6} />}
|
||||
/>
|
||||
<HeroStat
|
||||
tone={streak > 0 ? 'warning' : 'muted'}
|
||||
label={t('dashboard.stat.streak')}
|
||||
value={`${streak}`}
|
||||
subvalue={t('dashboard.stat.streak.subtitle', { n: streak })}
|
||||
icon={<Flame size={14} strokeWidth={2.6} />}
|
||||
/>
|
||||
<HeroStat
|
||||
tone="info"
|
||||
@@ -117,7 +138,7 @@ export default function Dashboard(): JSX.Element {
|
||||
? t('dashboard.stat.next.subtitle_paused')
|
||||
: t('dashboard.stat.next.subtitle_running')
|
||||
}
|
||||
icon={<Flame size={14} strokeWidth={2.6} />}
|
||||
icon={<Activity size={14} strokeWidth={2.6} />}
|
||||
/>
|
||||
<HeroStat
|
||||
tone={gamesEnabled ? 'success' : 'muted'}
|
||||
@@ -143,6 +164,16 @@ export default function Dashboard(): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<HistoryHeatmap
|
||||
history={history}
|
||||
exercises={exercises}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paused && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
@@ -214,7 +245,7 @@ function HeroStat({
|
||||
subvalue,
|
||||
icon
|
||||
}: {
|
||||
tone: 'accent' | 'info' | 'success' | 'muted'
|
||||
tone: 'accent' | 'info' | 'success' | 'warning' | 'muted'
|
||||
label: string
|
||||
value: string
|
||||
subvalue?: string
|
||||
@@ -227,7 +258,9 @@ function HeroStat({
|
||||
? 'bg-info'
|
||||
: tone === 'success'
|
||||
? 'bg-success'
|
||||
: 'bg-text/40'
|
||||
: tone === 'warning'
|
||||
? 'bg-warning'
|
||||
: 'bg-text/40'
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-2xl p-5 shadow-card dark:ring-0.5 dark:ring-hairline/30">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useT } from '../i18n'
|
||||
import type {
|
||||
Language,
|
||||
NotificationMode,
|
||||
QuietHours,
|
||||
Settings as SettingsType,
|
||||
Theme
|
||||
} from '@shared/types'
|
||||
@@ -91,6 +92,29 @@ export default function SettingsPage(): JSX.Element {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<SectionHeader title={t('settings.section.quiet')} />
|
||||
<Card className="mb-6">
|
||||
<ToggleRow
|
||||
label={t('settings.quiet.enabled.label')}
|
||||
hint={t('settings.quiet.enabled.hint')}
|
||||
checked={settings.quietHours.enabled}
|
||||
onChange={(v) =>
|
||||
patch({ quietHours: { ...settings.quietHours, enabled: v } })
|
||||
}
|
||||
/>
|
||||
<QuietTimesRow
|
||||
qh={settings.quietHours}
|
||||
onChange={(qh) => patch({ quietHours: qh })}
|
||||
disabled={!settings.quietHours.enabled}
|
||||
/>
|
||||
<QuietDaysRow
|
||||
qh={settings.quietHours}
|
||||
onChange={(qh) => patch({ quietHours: qh })}
|
||||
disabled={!settings.quietHours.enabled}
|
||||
last
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<SectionHeader title={t('settings.section.window')} />
|
||||
<Card className="mb-6">
|
||||
<ToggleRow
|
||||
@@ -168,6 +192,108 @@ function ToggleRow({
|
||||
)
|
||||
}
|
||||
|
||||
function QuietTimesRow({
|
||||
qh,
|
||||
onChange,
|
||||
disabled,
|
||||
last = false
|
||||
}: {
|
||||
qh: QuietHours
|
||||
onChange: (next: QuietHours) => void
|
||||
disabled?: boolean
|
||||
last?: boolean
|
||||
}): JSX.Element {
|
||||
const { t } = useT()
|
||||
return (
|
||||
<Row last={last} className={disabled ? 'opacity-50' : ''}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[15px] font-semibold leading-tight">
|
||||
{t('settings.quiet.times.label')}
|
||||
</div>
|
||||
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||
{t('settings.quiet.times.hint')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="time"
|
||||
value={qh.from}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...qh, from: e.target.value })}
|
||||
className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num"
|
||||
/>
|
||||
<span className="text-text/45 text-[14px]">—</span>
|
||||
<input
|
||||
type="time"
|
||||
value={qh.to}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...qh, to: e.target.value })}
|
||||
className="h-9 px-3 rounded-xl bg-surface-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/45 transition-all border-0 font-mono-num"
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
function QuietDaysRow({
|
||||
qh,
|
||||
onChange,
|
||||
disabled,
|
||||
last = false
|
||||
}: {
|
||||
qh: QuietHours
|
||||
onChange: (next: QuietHours) => void
|
||||
disabled?: boolean
|
||||
last?: boolean
|
||||
}): JSX.Element {
|
||||
const { t, lang } = useT()
|
||||
const labels =
|
||||
lang === 'en'
|
||||
? ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
|
||||
|
||||
function toggle(d: number): void {
|
||||
const set = new Set(qh.days)
|
||||
if (set.has(d)) set.delete(d)
|
||||
else set.add(d)
|
||||
onChange({ ...qh, days: Array.from(set).sort() })
|
||||
}
|
||||
|
||||
return (
|
||||
<Row last={last} className={disabled ? 'opacity-50' : ''}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[15px] font-semibold leading-tight">
|
||||
{t('settings.quiet.days.label')}
|
||||
</div>
|
||||
<div className="text-[13px] text-text/65 mt-1 leading-snug">
|
||||
{t('settings.quiet.days.hint')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap justify-end max-w-[60%]">
|
||||
{labels.map((label, d) => {
|
||||
const on = qh.days.includes(d)
|
||||
return (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => toggle(d)}
|
||||
className={[
|
||||
'h-7 min-w-[28px] px-1.5 rounded-full text-[11px] font-semibold transition-all',
|
||||
on
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-surface-2 text-text/55 hover:text-text'
|
||||
].join(' ')}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectRow({
|
||||
label,
|
||||
hint,
|
||||
|
||||
Reference in New Issue
Block a user