Files
laude/src/renderer/src/pages/Dashboard.tsx
AnRil c9d4fc237e
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
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>
2026-05-18 12:41:13 +07:00

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