From 9c989612fe88d52b72bdb82edf3bc7a968e4c85b Mon Sep 17 00:00:00 2001 From: AnRil Date: Fri, 22 May 2026 15:06:25 +0700 Subject: [PATCH] fix(P1): delete-confirm, daily-goal closed UI, meeting indicator, modal-confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 не теряет данные после удаления упражнения. --- src/main/ipc.ts | 3 + src/main/meeting-detect.ts | 10 +++ src/preload/index.ts | 6 +- src/renderer/src/components/ExerciseCard.tsx | 52 ++++++++++++-- .../src/components/ui/ConfirmModal.tsx | 62 +++++++++++++++++ src/renderer/src/i18n/dict.ts | 24 +++++++ src/renderer/src/lib/history.ts | 20 ++++++ src/renderer/src/pages/Dashboard.tsx | 69 ++++++++++++++++++- src/renderer/src/pages/Settings.tsx | 18 +++-- src/shared/ipc.ts | 5 +- 10 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/ui/ConfirmModal.tsx diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 619f50c..0f9e0c1 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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()) diff --git a/src/main/meeting-detect.ts b/src/main/meeting-detect.ts index 6fc22c0..e16625f 100644 --- a/src/main/meeting-detect.ts +++ b/src/main/meeting-detect.ts @@ -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 { 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 { } if (cachedActive) { log.info('[meeting] no meeting processes — resuming reminders') + broadcast(false) } cachedActive = false return false diff --git a/src/preload/index.ts b/src/preload/index.ts index 57e5a3e..c714c77 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -48,6 +48,8 @@ const api = { getOsTheme: (): Promise<'light' | 'dark'> => ipcRenderer.invoke(IPC.getOsTheme), getAppVersion: (): Promise => ipcRenderer.invoke(IPC.getAppVersion), + getMeetingActive: (): Promise => + ipcRenderer.invoke(IPC.getMeetingActive), pauseAll: (): Promise => ipcRenderer.invoke(IPC.pauseAll), resumeAll: (): Promise => ipcRenderer.invoke(IPC.resumeAll), @@ -137,7 +139,9 @@ const api = { onUpdaterStatus: (h: Handler): Unsub => on(IPC.evtUpdaterStatus, h), onMaximizeChanged: (h: Handler): Unsub => - on(IPC.evtMaximizeChanged, h) + on(IPC.evtMaximizeChanged, h), + onMeetingChanged: (h: Handler): Unsub => + on(IPC.evtMeetingChanged, h) } contextBridge.exposeInMainWorld('api', api) diff --git a/src/renderer/src/components/ExerciseCard.tsx b/src/renderer/src/components/ExerciseCard.tsx index 701ada9..a36eab8 100644 --- a/src/renderer/src/components/ExerciseCard.tsx +++ b/src/renderer/src/components/ExerciseCard.tsx @@ -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 */}
-
- {isDue ? t('dashboard.stat.next.now') : t('fmt.through')} +
+ {goalReached ? ( + <> + + {t('exercise.goal_reached.kicker')} + + ) : isDue ? ( + t('dashboard.stat.next.now') + ) : ( + t('fmt.through') + )} + {exercise.adaptive && !goalReached && ( + + )}
- {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)}
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 ( + + + + + } + > +
+ {message} +
+
+ ) +} diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index 911f7d1..3258664 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -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', diff --git a/src/renderer/src/lib/history.ts b/src/renderer/src/lib/history.ts index 38d55e0..9a5e72e 100644 --- a/src/renderer/src/lib/history.ts +++ b/src/renderer/src/lib/history.ts @@ -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[], diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 80c1ae7..1e3e11c 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -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(null) + const [deleting, setDeleting] = useState(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 { )} + {!paused && meetingPaused && ( + +
+
+
+
+ {t('dashboard.meeting.title')} +
+
+ {t('dashboard.meeting.hint')} +
+
+
+ )} +
{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} /> + + { + if (deleting) void window.api.deleteExercise(deleting.id) + setDeleting(null) + }} + onCancel={() => setDeleting(null)} + />
) diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 9a4433f..856ff93 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -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(null) + const [confirmOpen, setConfirmOpen] = useState(false) // Простое toast'-сообщение в карточке; через 4 сек чистится. useEffect(() => { @@ -250,9 +252,8 @@ function DataCard(): JSX.Element { } } - async function onImport(): Promise { - // eslint-disable-next-line no-alert -- modal-confirm для destructive action - if (!window.confirm(t('settings.data.import.confirm'))) return + async function performImport(): Promise { + setConfirmOpen(false) setBusy(true) try { const r = await window.api.importState() @@ -294,7 +295,7 @@ function DataCard(): JSX.Element {