From e7c3088ee53f75df646cc6af6e1e02eafdd92db1 Mon Sep 17 00:00:00 2001 From: AnRil Date: Fri, 22 May 2026 23:34:41 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=20rapid-d?= =?UTF-8?q?ouble-click=20+=20i18n=20native=20dialogs=20+=20=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B9=20default=20exerciseName?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReminderApp MatchSummary: sentChallengesRef для дедупа rapid double-click на ✓ — раньше один и тот же challenge мог записаться в историю несколько раз, давая лишние reps. Ref сбрасывается на новый match. ExerciseCard «Готово» (для due-упражнения): такая же ref-based дедуп. Окно ~1 сек между click → IPC.markDone → store.markDone обновляет nextFireAt → broadcastState → ticks broadcast → isDue=false. До этого быстрый double-click писал 2 entries с близкими ts. ipc.ts: title в showSaveDialog/showOpenDialog локализован по settings.language. Раньше всегда был русский в EN-локали. Challenges editor: EMPTY_DRAFT.exerciseName: '' вместо 'Приседания'. В EN-локали дефолтный русский текст выглядел багом. Required-валидация не пускает пустое значение в save. --- src/main/ipc.ts | 10 ++++++++-- src/renderer/src/ReminderApp.tsx | 11 +++++++++++ src/renderer/src/components/ExerciseCard.tsx | 18 ++++++++++++++++-- src/renderer/src/pages/Challenges.tsx | 6 +++++- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 99a3c19..86a4d42 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -347,8 +347,12 @@ export function registerIpc(): void { .replace(/[:T]/g, '-') .slice(0, 16) const defaultPath = `laude-backup-${stamp}.json` + // Native-диалоги OS читают локаль из системы. Title — единственная + // строка которую мы контролируем; локализуем по settings.language. + const lang = getState().settings.language ?? 'ru' const result = await dialog.showSaveDialog(win!, { - title: 'Сохранить резервную копию', + title: + lang === 'en' ? 'Save backup' : 'Сохранить резервную копию', defaultPath, filters: [{ name: 'JSON', extensions: ['json'] }] }) @@ -367,8 +371,10 @@ export function registerIpc(): void { ipcMain.handle(IPC.importState, async (event) => { const win = BrowserWindow.fromWebContents(event.sender) ?? undefined + const lang = getState().settings.language ?? 'ru' const result = await dialog.showOpenDialog(win!, { - title: 'Восстановить из резервной копии', + title: + lang === 'en' ? 'Restore from backup' : 'Восстановить из резервной копии', properties: ['openFile'], filters: [{ name: 'JSON', extensions: ['json'] }] }) diff --git a/src/renderer/src/ReminderApp.tsx b/src/renderer/src/ReminderApp.tsx index 53f08b5..90d160e 100644 --- a/src/renderer/src/ReminderApp.tsx +++ b/src/renderer/src/ReminderApp.tsx @@ -39,6 +39,10 @@ export default function ReminderApp(): JSX.Element { const [mode, setMode] = useState({ kind: 'idle' }) const [settings, setSettings] = useState(null) const settingsRef = useRef(null) + // ChallengeId'ы, для которых уже отправили markChallengeDone IPC. ref, + // не state — нужен только для дедупа rapid double-click. Сбрасывается + // когда приходит новый match summary (см. onMatchEnd ниже). + const sentChallengesRef = useRef>(new Set()) useEffect(() => { settingsRef.current = settings @@ -66,6 +70,8 @@ export default function ReminderApp(): JSX.Element { } }) const u2 = window.api.onMatchEnd((summary) => { + // Новый матч — сбрасываем дедуп challenge'ей. + sentChallengesRef.current = new Set() setMode({ kind: 'match', summary, done: new Set() }) const s = settingsRef.current if (s?.soundEnabled) playBeep() @@ -145,6 +151,11 @@ export default function ReminderApp(): JSX.Element { done={mode.done} lang={lang} onMarkDone={(id) => { + // Дедупликация: rapid double-click может два раза вызвать + // onMarkDone до того как `disabled={done}` доедет до DOM. + // Раньше это писало в историю дважды → лишние +N reps. + if (sentChallengesRef.current.has(id)) return + sentChallengesRef.current.add(id) // 1) IPC: записываем в историю (раньше делали только локальный set, // из-за чего матч-челленджи не считались в стрик/achievements). const result = mode.summary.results.find((r) => r.challengeId === id) diff --git a/src/renderer/src/components/ExerciseCard.tsx b/src/renderer/src/components/ExerciseCard.tsx index a36eab8..b4b2499 100644 --- a/src/renderer/src/components/ExerciseCard.tsx +++ b/src/renderer/src/components/ExerciseCard.tsx @@ -1,6 +1,6 @@ import { motion } from 'framer-motion' import { Check, MoreHorizontal, Brain, CheckCircle2 } from 'lucide-react' -import { useState } from 'react' +import { useRef, useState } from 'react' import type { Exercise, Tick } from '@shared/types' import { Icon } from '../lib/icon' import { formatCountdown } from '../lib/format' @@ -43,6 +43,20 @@ export function ExerciseCard({ // Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем. const isDue = ms <= 0 && exercise.enabled && !goalReached const [menuOpen, setMenuOpen] = useState(false) + // Дедуп rapid double-click на «Готово». Между кликом и обновлением + // nextFireAt (через broadcastState) есть окно ~1 сек, в которое можно + // вызвать markDone повторно и записать лишний entry в историю. + const markDoneInFlightRef = useRef(false) + const handleMarkDone = (): void => { + if (markDoneInFlightRef.current) return + markDoneInFlightRef.current = true + onMarkDone() + // К моменту окончания таймаута isDue уже false (после store-tick), кнопка + // не рендерится — флаг чистим на всякий случай для будущих кейсов. + setTimeout(() => { + markDoneInFlightRef.current = false + }, 1000) + } const { t, lang } = useT() // Ring math @@ -213,7 +227,7 @@ export function ExerciseCard({ {t('btn.done')} diff --git a/src/renderer/src/pages/Challenges.tsx b/src/renderer/src/pages/Challenges.tsx index c4c5acb..a9d5a4b 100644 --- a/src/renderer/src/pages/Challenges.tsx +++ b/src/renderer/src/pages/Challenges.tsx @@ -22,12 +22,16 @@ const GAME_NAMES: Record = { type Draft = Omit +// exerciseName умышленно пустой — пусть пользователь сам выберет что +// делать. Раньше дефолт был «Приседания» — в EN-локали это выглядело как +// баг (русский текст в английском UI). Required-валидация всё равно +// требует непустого значения перед save. const EMPTY_DRAFT: Draft = { name: '', gameId: 'dota2', stat: 'deaths', multiplier: 3, - exerciseName: 'Приседания', + exerciseName: '', icon: 'Activity', enabled: true }