fix: дедуп rapid-double-click + i18n native dialogs + пустой default exerciseName
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.
This commit is contained in:
@@ -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'] }]
|
||||
})
|
||||
|
||||
@@ -39,6 +39,10 @@ export default function ReminderApp(): JSX.Element {
|
||||
const [mode, setMode] = useState<Mode>({ kind: 'idle' })
|
||||
const [settings, setSettings] = useState<Settings | null>(null)
|
||||
const settingsRef = useRef<Settings | null>(null)
|
||||
// ChallengeId'ы, для которых уже отправили markChallengeDone IPC. ref,
|
||||
// не state — нужен только для дедупа rapid double-click. Сбрасывается
|
||||
// когда приходит новый match summary (см. onMatchEnd ниже).
|
||||
const sentChallengesRef = useRef<Set<string>>(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)
|
||||
|
||||
@@ -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({
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onClick={onMarkDone}
|
||||
onClick={handleMarkDone}
|
||||
className="mt-4 w-full h-11 rounded-xl bg-accent text-white text-[15px] font-bold inline-flex items-center justify-center gap-1.5 active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Check size={15} strokeWidth={2.5} /> {t('btn.done')}
|
||||
|
||||
@@ -22,12 +22,16 @@ const GAME_NAMES: Record<GameId, string> = {
|
||||
|
||||
type Draft = Omit<Challenge, 'id'>
|
||||
|
||||
// exerciseName умышленно пустой — пусть пользователь сам выберет что
|
||||
// делать. Раньше дефолт был «Приседания» — в EN-локали это выглядело как
|
||||
// баг (русский текст в английском UI). Required-валидация всё равно
|
||||
// требует непустого значения перед save.
|
||||
const EMPTY_DRAFT: Draft = {
|
||||
name: '',
|
||||
gameId: 'dota2',
|
||||
stat: 'deaths',
|
||||
multiplier: 3,
|
||||
exerciseName: 'Приседания',
|
||||
exerciseName: '',
|
||||
icon: 'Activity',
|
||||
enabled: true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user