fix(P1): delete-confirm, daily-goal closed UI, meeting indicator, modal-confirm

P1 #4 — ConfirmModal (новый src/renderer/src/components/ui/ConfirmModal.tsx)
    с iOS-стилем + focus-trap (через Modal). Delete упражнения в Dashboard
    теперь спрашивает «Удалить упражнение?» с destructive-кнопкой.

P1 #5 — Daily goal closed UI. ExerciseCard принимает doneToday prop
    и при `done >= dailyGoal` показывает «Цель закрыта · 100/100»
    вместо запутанного «25ч 13м» countdown'а. Цвет — success-зелёный.

P1 #6 — Meeting auto-pause indicator. Новый IPC.getMeetingActive +
    evtMeetingChanged event. meeting-detect broadcast'ит изменения
    состояния. Dashboard показывает info-баннер «Не дёргаем — ты на
    встрече» когда meetingAutoPause включён и хотя бы один meeting
    процесс запущен.

P1 #7 — Native window.confirm() заменён на ConfirmModal в Settings
    DataCard для restore-операции. Теперь iOS-style с destructive
    confirm-кнопкой и focus-trap'ом.

Заодно P2 #8: Brain-иконка-badge на ExerciseCard для adaptive
    упражнений — пользователь видит почему «Next» не строго равен
    intervalMinutes.
P2 #12: dailyReps/dailyRepsRange/totalDoneReps/repsDoneTodayForExercise
    используют entry.reps как fallback — heatmap не теряет данные
    после удаления упражнения.
This commit is contained in:
AnRil
2026-05-22 15:06:25 +07:00
parent 17df87b3aa
commit 9c989612fe
10 changed files with 254 additions and 15 deletions

View File

@@ -49,6 +49,7 @@ import {
getUpdaterStatus,
quitAndInstall
} from './updater'
import { isMeetingActiveSync } from './meeting-detect'
import {
validateActualReps,
validateChallengeInput,
@@ -187,6 +188,8 @@ export function registerIpc(): void {
ipcMain.handle(IPC.getAppVersion, () => app.getVersion())
ipcMain.handle(IPC.getMeetingActive, () => isMeetingActiveSync())
ipcMain.handle(IPC.quit, () => app.quit())
ipcMain.handle(IPC.reminderClose, () => hideReminderWindow())

View File

@@ -16,8 +16,16 @@
*/
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { BrowserWindow } from 'electron'
import { IPC } from '@shared/ipc'
import { log } from './logger'
function broadcast(active: boolean): void {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send(IPC.evtMeetingChanged, active)
}
}
const execAsync = promisify(exec)
/**
@@ -65,6 +73,7 @@ export async function isMeetingActive(): Promise<boolean> {
if (lower.includes(`"${proc}",`)) {
if (!cachedActive) {
log.info(`[meeting] detected ${proc} — pausing reminders`)
broadcast(true)
}
cachedActive = true
return true
@@ -72,6 +81,7 @@ export async function isMeetingActive(): Promise<boolean> {
}
if (cachedActive) {
log.info('[meeting] no meeting processes — resuming reminders')
broadcast(false)
}
cachedActive = false
return false

View File

@@ -48,6 +48,8 @@ const api = {
getOsTheme: (): Promise<'light' | 'dark'> =>
ipcRenderer.invoke(IPC.getOsTheme),
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IPC.getAppVersion),
getMeetingActive: (): Promise<boolean> =>
ipcRenderer.invoke(IPC.getMeetingActive),
pauseAll: (): Promise<void> => ipcRenderer.invoke(IPC.pauseAll),
resumeAll: (): Promise<void> => ipcRenderer.invoke(IPC.resumeAll),
@@ -137,7 +139,9 @@ const api = {
onUpdaterStatus: (h: Handler<UpdaterStatus>): Unsub =>
on(IPC.evtUpdaterStatus, h),
onMaximizeChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMaximizeChanged, h)
on(IPC.evtMaximizeChanged, h),
onMeetingChanged: (h: Handler<boolean>): Unsub =>
on(IPC.evtMeetingChanged, h)
}
contextBridge.exposeInMainWorld('api', api)

View File

@@ -1,5 +1,5 @@
import { motion } from 'framer-motion'
import { Check, MoreHorizontal } from 'lucide-react'
import { Check, MoreHorizontal, Brain, CheckCircle2 } from 'lucide-react'
import { useState } from 'react'
import type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon'
@@ -10,6 +10,8 @@ import { useT } from '../i18n'
type Props = {
exercise: Exercise
tick?: Tick
/** Сделано повторений сегодня (для daily-goal индикатора). */
doneToday?: number
onEdit: () => void
onDelete: () => void
onToggle: (enabled: boolean) => void
@@ -24,6 +26,7 @@ type Props = {
export function ExerciseCard({
exercise,
tick,
doneToday,
onEdit,
onDelete,
onToggle,
@@ -33,7 +36,12 @@ export function ExerciseCard({
const total = exercise.intervalMinutes * 60_000
const remaining = Math.max(0, Math.min(total, ms))
const elapsedPct = total > 0 ? 1 - remaining / total : 0
const isDue = ms <= 0 && exercise.enabled
const goalReached =
exercise.dailyGoal !== undefined &&
exercise.dailyGoal > 0 &&
(doneToday ?? 0) >= exercise.dailyGoal
// Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем.
const isDue = ms <= 0 && exercise.enabled && !goalReached
const [menuOpen, setMenuOpen] = useState(false)
const { t, lang } = useT()
@@ -148,16 +156,48 @@ export function ExerciseCard({
{/* Countdown + switch */}
<div className="flex items-end justify-between mt-3.5">
<div>
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold">
{isDue ? t('dashboard.stat.next.now') : t('fmt.through')}
<div className="text-[12px] text-text/60 uppercase tracking-wider font-semibold inline-flex items-center gap-1">
{goalReached ? (
<>
<CheckCircle2
size={11}
strokeWidth={2.5}
className="text-success"
/>
{t('exercise.goal_reached.kicker')}
</>
) : isDue ? (
t('dashboard.stat.next.now')
) : (
t('fmt.through')
)}
{exercise.adaptive && !goalReached && (
<Brain
size={11}
strokeWidth={2.5}
className="text-info ml-0.5"
aria-label={t('exercise.adaptive.badge')}
/>
)}
</div>
<div
className={[
'font-mono-num text-[24px] font-bold leading-none mt-1 tracking-tight',
isDue ? 'text-accent' : 'text-text'
goalReached
? 'text-success'
: isDue
? 'text-accent'
: 'text-text'
].join(' ')}
>
{exercise.enabled ? formatCountdown(ms, lang) : t('fmt.paused')}
{!exercise.enabled
? t('fmt.paused')
: goalReached
? t('exercise.goal_reached.value', {
done: doneToday ?? 0,
goal: exercise.dailyGoal ?? 0
})
: formatCountdown(ms, lang)}
</div>
</div>
<Switch

View File

@@ -0,0 +1,62 @@
import { Modal } from './Modal'
import { Button } from './Button'
import { useT } from '../../i18n'
type Props = {
open: boolean
title: string
/** Текст вопроса (одно-два предложения). */
message: string
/** Текст кнопки подтверждения, default «OK». */
confirmLabel?: string
/** Текст кнопки отмены, default «Отмена». */
cancelLabel?: string
/** Подсветить confirm красным (delete-style). */
destructive?: boolean
onConfirm: () => void
onCancel: () => void
}
/**
* iOS-style confirm-диалог. Заменяет `window.confirm()` — нативный prompt
* выглядит инородно в дизайне приложения, без вибрации accent-цвета и без
* focus-trap'a. Использует наш Modal под капотом, поэтому получаем
* focus-trap и Esc-to-cancel бесплатно.
*/
export function ConfirmModal({
open,
title,
message,
confirmLabel,
cancelLabel,
destructive,
onConfirm,
onCancel
}: Props): JSX.Element {
const { t } = useT()
return (
<Modal
open={open}
onClose={onCancel}
title={title}
size="sm"
footer={
<>
<Button variant="plain" onClick={onCancel}>
{cancelLabel ?? t('btn.cancel')}
</Button>
<Button
variant={destructive ? 'destructive' : 'filled'}
onClick={onConfirm}
>
{confirmLabel ?? t('btn.ok')}
</Button>
</>
}
>
<div className="text-[14px] text-text/85 leading-relaxed py-1">
{message}
</div>
</Modal>
)
}

View File

@@ -31,6 +31,15 @@ export const ru: Dict = {
'btn.add': 'Добавить',
'btn.new': 'Новый',
'btn.cancel': 'Отмена',
'btn.ok': 'OK',
'btn.delete.confirm': 'Удалить',
'exercise.delete.title': 'Удалить упражнение?',
'exercise.delete.body':
'Упражнение «{name}» будет удалено. История останется, но в ней останется только название.',
'exercise.goal_reached.kicker': 'Цель закрыта',
'exercise.goal_reached.value': '{done}/{goal}',
'exercise.adaptive.badge': 'адаптивный режим',
'settings.data.import.modal.title': 'Восстановить из файла?',
'btn.save': 'Сохранить',
'btn.done': 'Готово',
'btn.start': 'Старт',
@@ -69,6 +78,9 @@ export const ru: Dict = {
'dashboard.stat.tracking.subtitle_off': 'выключен',
'dashboard.paused.title': 'Напоминания на паузе',
'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт',
'dashboard.meeting.title': 'Не дёргаем — ты на встрече',
'dashboard.meeting.hint':
'Запущен Zoom / Teams / Discord / Webex / Slack-huddle. Напоминания возобновятся когда закроешь.',
'dashboard.empty.title': 'Программа пуста',
'dashboard.empty.hint': 'Добавь первое упражнение, чтобы начать',
@@ -346,6 +358,15 @@ export const en: Dict = {
'btn.add': 'Add',
'btn.new': 'New',
'btn.cancel': 'Cancel',
'btn.ok': 'OK',
'btn.delete.confirm': 'Delete',
'exercise.delete.title': 'Delete exercise?',
'exercise.delete.body':
'Exercise "{name}" will be removed. History stays but will only show the name.',
'exercise.goal_reached.kicker': 'Goal hit',
'exercise.goal_reached.value': '{done}/{goal}',
'exercise.adaptive.badge': 'adaptive mode',
'settings.data.import.modal.title': 'Restore from file?',
'btn.save': 'Save',
'btn.done': 'Done',
'btn.start': 'Start',
@@ -383,6 +404,9 @@ export const en: Dict = {
'dashboard.stat.tracking.subtitle_on': 'real-time',
'dashboard.stat.tracking.subtitle_off': 'disabled',
'dashboard.paused.title': 'Reminders paused',
'dashboard.meeting.title': "You're in a meeting — won't interrupt",
'dashboard.meeting.hint':
'Zoom / Teams / Discord / Webex / Slack-huddle is running. Reminders resume when you close it.',
'dashboard.paused.hint': 'Resume to continue countdown',
'dashboard.empty.title': 'Program is empty',
'dashboard.empty.hint': 'Add your first exercise to start',

View File

@@ -39,6 +39,26 @@ function shiftDays(base: Date, dayDelta: number): Date {
* связанного Exercise)
* 3. byId.get(exerciseId).reps — fallback для старых entries без snapshot'а
*/
/**
* Сколько reps конкретное упражнение принесло за сегодня. Учитываем как
* обычные «по таймеру», так и match-челленджи (если их exerciseId совпадает,
* чего обычно нет; но fallback не помешает).
*/
export function repsDoneTodayForExercise(
entries: HistoryEntry[],
exercise: Exercise
): number {
const today = todayKey()
let sum = 0
for (const e of entries) {
if (e.action !== 'done') continue
if (e.exerciseId !== exercise.id) continue
if (dayKey(e.ts) !== today) continue
sum += e.actualReps ?? e.reps ?? exercise.reps
}
return sum
}
export function dailyReps(
entries: HistoryEntry[],
exercises: Exercise[],

View File

@@ -1,22 +1,37 @@
import { useEffect, useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus, Pause, Play, Flame, Activity, TrendingUp } from 'lucide-react'
import {
Plus,
Pause,
Play,
Flame,
Activity,
TrendingUp,
Video
} from 'lucide-react'
import { useAppStore } from '../store/appStore'
import { ExerciseCard } from '../components/ExerciseCard'
import { ExerciseEditor } from '../components/ExerciseEditor'
import { HistoryHeatmap } from '../components/HistoryHeatmap'
import { AchievementsCard } from '../components/AchievementsCard'
import { Button } from '../components/ui/Button'
import { ConfirmModal } from '../components/ui/ConfirmModal'
import type { Exercise, HistoryEntry } from '@shared/types'
import { formatCountdown } from '../lib/format'
import { useT } from '../i18n'
import { currentStreak, dailyReps, todayKey } from '../lib/history'
import {
currentStreak,
dailyReps,
repsDoneTodayForExercise,
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 [deleting, setDeleting] = useState<Exercise | null>(null)
const { t, lang } = useT()
// Memoise the exercises array reference so downstream useMemos don't fire
@@ -34,6 +49,16 @@ export default function Dashboard(): JSX.Element {
void window.api.getHistory().then(setHistory)
}, [exercises])
// Meeting auto-pause indicator: подписываемся на evtMeetingChanged +
// запрашиваем актуальное значение при mount. Показываем баннер только
// если фича включена в settings.
const [meetingActive, setMeetingActive] = useState(false)
useEffect(() => {
void window.api.getMeetingActive().then(setMeetingActive)
return window.api.onMeetingChanged(setMeetingActive)
}, [])
const meetingPaused = meetingActive && settings?.meetingAutoPause === true
const todayDone = useMemo(
() => dailyReps(history, exercises, todayKey()),
[history, exercises]
@@ -211,6 +236,26 @@ export default function Dashboard(): JSX.Element {
</motion.div>
)}
{!paused && meetingPaused && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6 rounded-2xl bg-info/12 p-4 flex items-center gap-3"
>
<div className="w-10 h-10 rounded-xl bg-info/18 text-info grid place-items-center shrink-0">
<Video size={18} strokeWidth={2.5} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[16px] font-semibold leading-tight">
{t('dashboard.meeting.title')}
</div>
<div className="text-[14px] text-text/70 mt-1">
{t('dashboard.meeting.hint')}
</div>
</div>
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
<AnimatePresence>
{exercises.map((ex) => (
@@ -218,8 +263,13 @@ export default function Dashboard(): JSX.Element {
key={ex.id}
exercise={ex}
tick={ticks[ex.id]}
doneToday={
ex.dailyGoal !== undefined
? repsDoneTodayForExercise(history, ex)
: undefined
}
onEdit={() => openEdit(ex)}
onDelete={() => window.api.deleteExercise(ex.id)}
onDelete={() => setDeleting(ex)}
onToggle={(v) => window.api.toggleExercise(ex.id, v)}
onMarkDone={() => window.api.markDone(ex.id)}
/>
@@ -247,6 +297,19 @@ export default function Dashboard(): JSX.Element {
onClose={() => setEditorOpen(false)}
onSave={handleSave}
/>
<ConfirmModal
open={deleting !== null}
title={t('exercise.delete.title')}
message={t('exercise.delete.body', { name: deleting?.name ?? '' })}
confirmLabel={t('btn.delete.confirm')}
destructive
onConfirm={() => {
if (deleting) void window.api.deleteExercise(deleting.id)
setDeleting(null)
}}
onCancel={() => setDeleting(null)}
/>
</div>
</div>
)

View File

@@ -4,6 +4,7 @@ import { Switch } from '../components/ui/Switch'
import { Card, Row, SectionHeader } from '../components/ui/Card'
import { UpdaterCard } from '../components/UpdaterCard'
import { WhatsNewModal } from '../components/WhatsNewModal'
import { ConfirmModal } from '../components/ui/ConfirmModal'
import { RELEASE_NOTES } from '@shared/release-notes'
import { useT } from '../i18n'
import type {
@@ -228,6 +229,7 @@ function DataCard(): JSX.Element {
const { t } = useT()
const [busy, setBusy] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
// Простое toast'-сообщение в карточке; через 4 сек чистится.
useEffect(() => {
@@ -250,9 +252,8 @@ function DataCard(): JSX.Element {
}
}
async function onImport(): Promise<void> {
// eslint-disable-next-line no-alert -- modal-confirm для destructive action
if (!window.confirm(t('settings.data.import.confirm'))) return
async function performImport(): Promise<void> {
setConfirmOpen(false)
setBusy(true)
try {
const r = await window.api.importState()
@@ -294,7 +295,7 @@ function DataCard(): JSX.Element {
</div>
</div>
<button
onClick={onImport}
onClick={() => setConfirmOpen(true)}
disabled={busy}
className="h-9 px-4 rounded-xl bg-surface-2 hover:bg-hairline/25 text-[14px] font-semibold transition-colors disabled:opacity-50"
>
@@ -306,6 +307,15 @@ function DataCard(): JSX.Element {
{toast}
</div>
)}
<ConfirmModal
open={confirmOpen}
title={t('settings.data.import.modal.title')}
message={t('settings.data.import.confirm')}
confirmLabel={t('settings.data.import.btn')}
destructive
onConfirm={performImport}
onCancel={() => setConfirmOpen(false)}
/>
</Card>
)
}

View File

@@ -66,5 +66,8 @@ export const IPC = {
evtAccentChanged: 'evt:accentChanged',
evtGamesChanged: 'evt:gamesChanged',
evtUpdaterStatus: 'evt:updaterStatus',
evtMaximizeChanged: 'evt:maximizeChanged'
evtMaximizeChanged: 'evt:maximizeChanged',
evtMeetingChanged: 'evt:meetingChanged',
getMeetingActive: 'system:meetingActive'
} as const