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:
AnRil
2026-05-22 23:34:41 +07:00
parent 2b7eb412c7
commit e7c3088ee5
4 changed files with 40 additions and 5 deletions

View File

@@ -347,8 +347,12 @@ export function registerIpc(): void {
.replace(/[:T]/g, '-') .replace(/[:T]/g, '-')
.slice(0, 16) .slice(0, 16)
const defaultPath = `laude-backup-${stamp}.json` const defaultPath = `laude-backup-${stamp}.json`
// Native-диалоги OS читают локаль из системы. Title — единственная
// строка которую мы контролируем; локализуем по settings.language.
const lang = getState().settings.language ?? 'ru'
const result = await dialog.showSaveDialog(win!, { const result = await dialog.showSaveDialog(win!, {
title: 'Сохранить резервную копию', title:
lang === 'en' ? 'Save backup' : 'Сохранить резервную копию',
defaultPath, defaultPath,
filters: [{ name: 'JSON', extensions: ['json'] }] filters: [{ name: 'JSON', extensions: ['json'] }]
}) })
@@ -367,8 +371,10 @@ export function registerIpc(): void {
ipcMain.handle(IPC.importState, async (event) => { ipcMain.handle(IPC.importState, async (event) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined const win = BrowserWindow.fromWebContents(event.sender) ?? undefined
const lang = getState().settings.language ?? 'ru'
const result = await dialog.showOpenDialog(win!, { const result = await dialog.showOpenDialog(win!, {
title: 'Восстановить из резервной копии', title:
lang === 'en' ? 'Restore from backup' : 'Восстановить из резервной копии',
properties: ['openFile'], properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }] filters: [{ name: 'JSON', extensions: ['json'] }]
}) })

View File

@@ -39,6 +39,10 @@ export default function ReminderApp(): JSX.Element {
const [mode, setMode] = useState<Mode>({ kind: 'idle' }) const [mode, setMode] = useState<Mode>({ kind: 'idle' })
const [settings, setSettings] = useState<Settings | null>(null) const [settings, setSettings] = useState<Settings | null>(null)
const settingsRef = useRef<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(() => { useEffect(() => {
settingsRef.current = settings settingsRef.current = settings
@@ -66,6 +70,8 @@ export default function ReminderApp(): JSX.Element {
} }
}) })
const u2 = window.api.onMatchEnd((summary) => { const u2 = window.api.onMatchEnd((summary) => {
// Новый матч — сбрасываем дедуп challenge'ей.
sentChallengesRef.current = new Set()
setMode({ kind: 'match', summary, done: new Set() }) setMode({ kind: 'match', summary, done: new Set() })
const s = settingsRef.current const s = settingsRef.current
if (s?.soundEnabled) playBeep() if (s?.soundEnabled) playBeep()
@@ -145,6 +151,11 @@ export default function ReminderApp(): JSX.Element {
done={mode.done} done={mode.done}
lang={lang} lang={lang}
onMarkDone={(id) => { onMarkDone={(id) => {
// Дедупликация: rapid double-click может два раза вызвать
// onMarkDone до того как `disabled={done}` доедет до DOM.
// Раньше это писало в историю дважды → лишние +N reps.
if (sentChallengesRef.current.has(id)) return
sentChallengesRef.current.add(id)
// 1) IPC: записываем в историю (раньше делали только локальный set, // 1) IPC: записываем в историю (раньше делали только локальный set,
// из-за чего матч-челленджи не считались в стрик/achievements). // из-за чего матч-челленджи не считались в стрик/achievements).
const result = mode.summary.results.find((r) => r.challengeId === id) const result = mode.summary.results.find((r) => r.challengeId === id)

View File

@@ -1,6 +1,6 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { Check, MoreHorizontal, Brain, CheckCircle2 } from 'lucide-react' 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 type { Exercise, Tick } from '@shared/types'
import { Icon } from '../lib/icon' import { Icon } from '../lib/icon'
import { formatCountdown } from '../lib/format' import { formatCountdown } from '../lib/format'
@@ -43,6 +43,20 @@ export function ExerciseCard({
// Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем. // Если цель закрыта — упражнение «отдыхает» до завтра, isDue не считаем.
const isDue = ms <= 0 && exercise.enabled && !goalReached const isDue = ms <= 0 && exercise.enabled && !goalReached
const [menuOpen, setMenuOpen] = useState(false) 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() const { t, lang } = useT()
// Ring math // Ring math
@@ -213,7 +227,7 @@ export function ExerciseCard({
<motion.button <motion.button
initial={{ opacity: 0, y: 4 }} initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }} 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" 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')} <Check size={15} strokeWidth={2.5} /> {t('btn.done')}

View File

@@ -22,12 +22,16 @@ const GAME_NAMES: Record<GameId, string> = {
type Draft = Omit<Challenge, 'id'> type Draft = Omit<Challenge, 'id'>
// exerciseName умышленно пустой — пусть пользователь сам выберет что
// делать. Раньше дефолт был «Приседания» — в EN-локали это выглядело как
// баг (русский текст в английском UI). Required-валидация всё равно
// требует непустого значения перед save.
const EMPTY_DRAFT: Draft = { const EMPTY_DRAFT: Draft = {
name: '', name: '',
gameId: 'dota2', gameId: 'dota2',
stat: 'deaths', stat: 'deaths',
multiplier: 3, multiplier: 3,
exerciseName: 'Приседания', exerciseName: '',
icon: 'Activity', icon: 'Activity',
enabled: true enabled: true
} }