From db18d0c512585910d84ffed6e13103c7573cceaf Mon Sep 17 00:00:00 2001 From: AnRil Date: Fri, 22 May 2026 15:15:41 +0700 Subject: [PATCH] fix(P2): celebration, TTS delay, match-close confirm, tracking badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 #9 — Achievement unlock celebration. AchievementsCard сравнивает текущие unlocked с persisted set'ом в localStorage. Новые играют scale+accent-glow анимацию 2.8 сек (1.4с × 2 повтора). После добавляются в celebrated — при следующем заходе не подпрыгивают. P2 #10 — Match summary close confirm. Если в Match Summary остались незакрытые челленджи, при close через X / Esc / btn — native confirm с количеством остатка. Пользователь не «пролетает» окно случайно. P2 #11 — TTS delay 800ms. Дикторский голос ждёт 800мс прежде чем проговорить — пользователь успевает decrement stepper если хочет сделать меньше планового. Иначе голос произносит планируемые reps уже когда юзер изменил цифру. P2 #13 — Tracking badge точность. Раньше зелёный если просто `gamesEnabled[id]`. Теперь зелёный только если live (enabled + integrationActive + launchOptionStatus='applied'). Введён tone 'warning' для «включена, но launch option ещё queued» с подсказкой «закрой Steam и снова открой». Dashboard подписывается на onGamesChanged + первый listGames на mount. --- src/renderer/src/ReminderApp.tsx | 45 ++++++++-- .../src/components/AchievementsCard.tsx | 89 +++++++++++++++++-- src/renderer/src/i18n/dict.ts | 5 ++ src/renderer/src/pages/Dashboard.tsx | 41 +++++++-- 4 files changed, 160 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/ReminderApp.tsx b/src/renderer/src/ReminderApp.tsx index d040bc7..53f08b5 100644 --- a/src/renderer/src/ReminderApp.tsx +++ b/src/renderer/src/ReminderApp.tsx @@ -28,6 +28,13 @@ type Mode = | { kind: 'exercise'; exercise: Exercise } | { kind: 'match'; summary: MatchSummary; done: Set } +/** Минимальный нативный confirm. В reminder-окне нет места для модалки, + * проще использовать встроенный диалог. */ +function nativeConfirm(message: string): boolean { + // eslint-disable-next-line no-alert -- reminder window не имеет своей Modal-обвязки + return window.confirm(message) +} + export default function ReminderApp(): JSX.Element { const [mode, setMode] = useState({ kind: 'idle' }) const [settings, setSettings] = useState(null) @@ -45,14 +52,17 @@ export default function ReminderApp(): JSX.Element { const s = settingsRef.current if (s?.soundEnabled) playBeep() if (s?.voicePromptsEnabled) { - // «{exercise.name}, {n} раз/раза/раз». Простая локальная фраза без - // ключа в dict — короткая команда, не нуждается в полном переводе. + // Задержка 800ms даёт пользователю шанс decrement'нуть stepper до + // фактического количества — TTS прозвучит уже под реальную цифру, + // если успел нажать -. Иначе скажет планируемые reps. const lang = s.language ?? 'ru' - const phrase = - lang === 'ru' - ? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}` - : `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}` - speak(phrase, lang) + setTimeout(() => { + const phrase = + lang === 'ru' + ? `${ex.name}. ${ex.reps} ${repWordRu(ex.reps)}` + : `${ex.name}. ${ex.reps} ${ex.reps === 1 ? 'rep' : 'reps'}` + speak(phrase, lang) + }, 800) } }) const u2 = window.api.onMatchEnd((summary) => { @@ -88,6 +98,27 @@ export default function ReminderApp(): JSX.Element { }, [mode.kind]) function close(): void { + // Если в Match Summary остались незакрытые челленджи — подтверждаем, + // чтобы пользователь не «пролетел» окно по привычке и не потерял + // мотивацию (и метрики стрик/today_done). + if (mode.kind === 'match') { + const remaining = mode.summary.results.filter( + (r) => !mode.done.has(r.challengeId) + ) + if (remaining.length > 0) { + const lang = settings?.language ?? 'ru' + const reps = remaining.reduce((s, r) => s + r.reps, 0) + const msg = + lang === 'ru' + ? `Осталось ${remaining.length} ${ + remaining.length === 1 ? 'челлендж' : 'челленджей' + } · ${reps} повторений. Закрыть окно?` + : `${remaining.length} ${ + remaining.length === 1 ? 'challenge' : 'challenges' + } left · ${reps} reps. Close anyway?` + if (!nativeConfirm(msg)) return + } + } setMode({ kind: 'idle' }) window.api.reminderClose() } diff --git a/src/renderer/src/components/AchievementsCard.tsx b/src/renderer/src/components/AchievementsCard.tsx index eb3c306..0c8bd3d 100644 --- a/src/renderer/src/components/AchievementsCard.tsx +++ b/src/renderer/src/components/AchievementsCard.tsx @@ -1,4 +1,5 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { motion } from 'framer-motion' import { Award, Activity, Flame, Sparkles, TrendingUp, Lock } from 'lucide-react' import type { Exercise, HistoryEntry } from '@shared/types' import { @@ -7,6 +8,27 @@ import { } from '../lib/achievements' import { useT } from '../i18n' +const CELEBRATED_KEY = 'laude:celebratedAchievements' + +function loadCelebrated(): Set { + try { + const raw = localStorage.getItem(CELEBRATED_KEY) + if (!raw) return new Set() + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? new Set(parsed) : new Set() + } catch { + return new Set() + } +} + +function saveCelebrated(set: Set): void { + try { + localStorage.setItem(CELEBRATED_KEY, JSON.stringify(Array.from(set))) + } catch { + /* localStorage может быть отключён — ничего страшного, просто будет celebrate каждый раз */ + } +} + const ICON_BY_NAME = { Activity, Flame, @@ -32,6 +54,32 @@ export function AchievementsCard({ history, exercises }: Props): JSX.Element { [history, exercises] ) + // Достижения, которые в этом mount пользователь впервые увидел unlocked + // (т.е. не были в localStorage до этого). Для них играем pulse-анимацию. + // После анимации помечаем как celebrated, чтобы при следующем рендере + // они уже не подпрыгивали. + const [freshlyUnlocked, setFreshlyUnlocked] = useState>( + () => new Set() + ) + useEffect(() => { + const celebrated = loadCelebrated() + const fresh = new Set() + for (const a of achievements) { + if (a.unlocked && !celebrated.has(a.def.id)) { + fresh.add(a.def.id) + celebrated.add(a.def.id) + } + } + if (fresh.size > 0) { + setFreshlyUnlocked(fresh) + saveCelebrated(celebrated) + // Снимаем «свежесть» через 5 сек чтобы pulse не крутился вечно. + const t = setTimeout(() => setFreshlyUnlocked(new Set()), 5_000) + return () => clearTimeout(t) + } + return undefined + }, [achievements]) + const unlocked = achievements.filter((a) => a.unlocked) const locked = achievements.filter((a) => !a.unlocked) // Сортируем locked по близости к unlock'у — чтобы «осталось 12» @@ -67,14 +115,24 @@ export function AchievementsCard({ history, exercises }: Props): JSX.Element {
{visible.map((a) => ( - + ))}
) } -function Badge({ a }: { a: AchievementProgress }): JSX.Element { +function Badge({ + a, + fresh +}: { + a: AchievementProgress + fresh: boolean +}): JSX.Element { const { t } = useT() const IconCmp = ICON_BY_NAME[a.def.icon as keyof typeof ICON_BY_NAME] ?? Award const pct = Math.min(100, Math.round((a.current / a.target) * 100)) @@ -86,7 +144,28 @@ function Badge({ a }: { a: AchievementProgress }): JSX.Element { }[a.def.tone] return ( -
)} -
+ ) } diff --git a/src/renderer/src/i18n/dict.ts b/src/renderer/src/i18n/dict.ts index 3258664..d4895c7 100644 --- a/src/renderer/src/i18n/dict.ts +++ b/src/renderer/src/i18n/dict.ts @@ -74,8 +74,11 @@ export const ru: Dict = { 'dashboard.stat.tracking': 'Трекинг матчей', 'dashboard.stat.tracking.on': 'On', 'dashboard.stat.tracking.off': 'Off', + 'dashboard.stat.tracking.pending': 'Setup', 'dashboard.stat.tracking.subtitle_on': 'в реальном времени', 'dashboard.stat.tracking.subtitle_off': 'выключен', + 'dashboard.stat.tracking.subtitle_pending': + 'нужно закрыть Steam и снова открыть', 'dashboard.paused.title': 'Напоминания на паузе', 'dashboard.paused.hint': 'Возобнови, чтобы продолжить отсчёт', 'dashboard.meeting.title': 'Не дёргаем — ты на встрече', @@ -401,8 +404,10 @@ export const en: Dict = { 'dashboard.stat.tracking': 'Match tracking', 'dashboard.stat.tracking.on': 'On', 'dashboard.stat.tracking.off': 'Off', + 'dashboard.stat.tracking.pending': 'Setup', 'dashboard.stat.tracking.subtitle_on': 'real-time', 'dashboard.stat.tracking.subtitle_off': 'disabled', + 'dashboard.stat.tracking.subtitle_pending': 'close & reopen Steam', 'dashboard.paused.title': 'Reminders paused', 'dashboard.meeting.title': "You're in a meeting — won't interrupt", 'dashboard.meeting.hint': diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 1e3e11c..b4b2f9e 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -16,7 +16,7 @@ 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 type { Exercise, GameStatus, HistoryEntry } from '@shared/types' import { formatCountdown } from '../lib/format' import { useT } from '../i18n' import { @@ -39,7 +39,26 @@ export default function Dashboard(): JSX.Element { // the parent re-renders even when nothing changed. const exercises = useMemo(() => state?.exercises ?? [], [state?.exercises]) const settings = state?.settings - const gamesEnabled = Object.values(state?.gamesEnabled ?? {}).some(Boolean) + + // Игры: запрашиваем реальный статус (integrationActive + launchOption + // applied), а не просто `state.gamesEnabled`. Без этого badge показывал + // success-зелёный даже когда launch option ещё «queued» (Steam перезапи- + // сывает) — пользователь думал что GSI работает, а fires не приходили. + const [games, setGames] = useState([]) + useEffect(() => { + void window.api.listGames().then(setGames) + return window.api.onGamesChanged(setGames) + }, []) + const gamesLive = games.some( + (g) => + g.enabled && + g.integrationActive && + g.launchOptionStatus === 'applied' + ) + // «Включена, но не готова» — отдельное состояние, в badge другой tone. + const gamesEnabledButNotLive = games.some( + (g) => g.enabled && (!g.integrationActive || g.launchOptionStatus !== 'applied') + ) // Local history mirror; reloaded only when exercises change (not on every // tick or settings tweak — those don't affect history). When ticks/settings @@ -183,23 +202,29 @@ export default function Dashboard(): JSX.Element { icon={} /> }