== История и стрики (#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>
289 lines
9.3 KiB
TypeScript
289 lines
9.3 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
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, 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)
|
|
const ticks = useAppStore((s) => s.ticks)
|
|
const [editorOpen, setEditorOpen] = useState(false)
|
|
const [editing, setEditing] = useState<Exercise | null>(null)
|
|
const { t, lang } = useT()
|
|
|
|
const exercises = state?.exercises ?? []
|
|
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
|
|
.map((e) => ({ id: e.id, ms: e.nextFireAt - Date.now() }))
|
|
.sort((a, b) => a.ms - b.ms)[0]
|
|
return {
|
|
total: exercises.length,
|
|
active: enabled.length,
|
|
nextMs: next?.ms ?? Infinity,
|
|
totalReps: enabled.reduce((s, e) => s + e.reps, 0)
|
|
}
|
|
}, [exercises, ticks])
|
|
|
|
const paused = !settings?.globalEnabled
|
|
|
|
function openCreate(): void {
|
|
setEditing(null)
|
|
setEditorOpen(true)
|
|
}
|
|
function openEdit(ex: Exercise): void {
|
|
setEditing(ex)
|
|
setEditorOpen(true)
|
|
}
|
|
async function handleSave(draft: {
|
|
name: string
|
|
reps: number
|
|
icon: string
|
|
intervalMinutes: number
|
|
enabled: boolean
|
|
}): Promise<void> {
|
|
if (editing) await window.api.updateExercise(editing.id, draft)
|
|
else await window.api.addExercise(draft)
|
|
setEditorOpen(false)
|
|
}
|
|
async function togglePause(): Promise<void> {
|
|
if (!settings) return
|
|
await window.api.updateSettings({ globalEnabled: !settings.globalEnabled })
|
|
}
|
|
|
|
const today = new Date().toLocaleDateString(
|
|
lang === 'en' ? 'en-US' : 'ru-RU',
|
|
{ weekday: 'long', day: 'numeric', month: 'long' }
|
|
)
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto">
|
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-10 pt-8 pb-12">
|
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
|
<div className="min-w-0">
|
|
<div className="text-[14px] text-text/65 font-semibold capitalize">
|
|
{today}
|
|
</div>
|
|
<h1 className="font-serif text-[34px] sm:text-[40px] leading-[1.02] tracking-tight mt-1 font-bold">
|
|
{t('dashboard.title')}
|
|
</h1>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="tinted" onClick={togglePause}>
|
|
{!paused ? (
|
|
<>
|
|
<Pause size={14} strokeWidth={2.5} /> {t('btn.pause')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Play size={14} strokeWidth={2.5} /> {t('btn.start')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button onClick={openCreate}>
|
|
<Plus size={15} strokeWidth={2.5} /> {t('btn.add')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-8">
|
|
<HeroStat
|
|
tone="accent"
|
|
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"
|
|
label={t('dashboard.stat.next')}
|
|
value={
|
|
stats.nextMs === Infinity
|
|
? '—'
|
|
: stats.nextMs <= 0
|
|
? t('dashboard.stat.next.now')
|
|
: formatCountdown(stats.nextMs, lang)
|
|
}
|
|
subvalue={
|
|
paused
|
|
? t('dashboard.stat.next.subtitle_paused')
|
|
: t('dashboard.stat.next.subtitle_running')
|
|
}
|
|
icon={<Activity size={14} strokeWidth={2.6} />}
|
|
/>
|
|
<HeroStat
|
|
tone={gamesEnabled ? 'success' : 'muted'}
|
|
label={t('dashboard.stat.tracking')}
|
|
value={
|
|
gamesEnabled
|
|
? t('dashboard.stat.tracking.on')
|
|
: t('dashboard.stat.tracking.off')
|
|
}
|
|
subvalue={
|
|
gamesEnabled
|
|
? t('dashboard.stat.tracking.subtitle_on')
|
|
: t('dashboard.stat.tracking.subtitle_off')
|
|
}
|
|
icon={
|
|
<span
|
|
className={[
|
|
'w-1.5 h-1.5 rounded-full',
|
|
gamesEnabled ? 'bg-white' : 'bg-text/30'
|
|
].join(' ')}
|
|
/>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{history.length > 0 && (
|
|
<div className="mb-8">
|
|
<HistoryHeatmap
|
|
history={history}
|
|
exercises={exercises}
|
|
lang={lang}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{paused && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="mb-6 rounded-2xl bg-warning/12 p-4 flex items-center gap-3"
|
|
>
|
|
<div className="w-10 h-10 rounded-xl bg-warning/18 text-warning grid place-items-center shrink-0">
|
|
<Pause size={18} strokeWidth={2.5} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[16px] font-semibold leading-tight">
|
|
{t('dashboard.paused.title')}
|
|
</div>
|
|
<div className="text-[14px] text-text/70 mt-1">
|
|
{t('dashboard.paused.hint')}
|
|
</div>
|
|
</div>
|
|
<Button variant="filled" size="sm" onClick={togglePause}>
|
|
<Play size={14} strokeWidth={2.5} /> {t('btn.start')}
|
|
</Button>
|
|
</motion.div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
|
|
<AnimatePresence>
|
|
{exercises.map((ex) => (
|
|
<ExerciseCard
|
|
key={ex.id}
|
|
exercise={ex}
|
|
tick={ticks[ex.id]}
|
|
onEdit={() => openEdit(ex)}
|
|
onDelete={() => window.api.deleteExercise(ex.id)}
|
|
onToggle={(v) => window.api.toggleExercise(ex.id, v)}
|
|
onMarkDone={() => window.api.markDone(ex.id)}
|
|
/>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{exercises.length === 0 && (
|
|
<div className="mt-12 text-center">
|
|
<div className="inline-flex w-14 h-14 rounded-2xl bg-accent text-white items-center justify-center mb-4">
|
|
<Plus size={24} strokeWidth={2.5} />
|
|
</div>
|
|
<div className="font-display text-[20px] font-semibold">
|
|
{t('dashboard.empty.title')}
|
|
</div>
|
|
<p className="text-[14px] text-text/55 mt-1">
|
|
{t('dashboard.empty.hint')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<ExerciseEditor
|
|
open={editorOpen}
|
|
exercise={editing}
|
|
onClose={() => setEditorOpen(false)}
|
|
onSave={handleSave}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HeroStat({
|
|
tone,
|
|
label,
|
|
value,
|
|
subvalue,
|
|
icon
|
|
}: {
|
|
tone: 'accent' | 'info' | 'success' | 'warning' | 'muted'
|
|
label: string
|
|
value: string
|
|
subvalue?: string
|
|
icon?: React.ReactNode
|
|
}): JSX.Element {
|
|
const toneBg =
|
|
tone === 'accent'
|
|
? 'bg-accent'
|
|
: tone === 'info'
|
|
? 'bg-info'
|
|
: tone === 'success'
|
|
? 'bg-success'
|
|
: 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">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<div
|
|
className={[
|
|
'w-7 h-7 rounded-lg grid place-items-center text-white',
|
|
toneBg
|
|
].join(' ')}
|
|
>
|
|
{icon}
|
|
</div>
|
|
<div className="text-[14px] text-text/75 font-semibold">{label}</div>
|
|
</div>
|
|
<div className="font-display text-[28px] font-bold tracking-tight leading-none">
|
|
{value}
|
|
</div>
|
|
{subvalue && (
|
|
<div className="text-[13px] text-text/60 mt-2 font-medium">
|
|
{subvalue}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|