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, '-')
|
.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'] }]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user