diff --git a/frontend/src/components/PomodoroWidget.tsx b/frontend/src/components/PomodoroWidget.tsx index 08fc456..a6a68cf 100644 --- a/frontend/src/components/PomodoroWidget.tsx +++ b/frontend/src/components/PomodoroWidget.tsx @@ -2,6 +2,7 @@ import { Link } from "react-router-dom"; import { usePomodoro } from "../context/PomodoroContext"; import { formatTime } from "../utils/time"; import { formatCycleLabel, phaseLabel } from "../utils/pomodoro"; +import { computeProgress } from "../utils/pomodoroCountdown"; import "./PomodoroWidget.css"; interface PomodoroWidgetProps { @@ -9,15 +10,13 @@ interface PomodoroWidgetProps { } export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps) { - const { status } = usePomodoro(); + const { status, remainingSeconds } = usePomodoro(); if (!status) return null; const isActive = status.status === "running" || status.status === "paused"; - const displaySeconds = isActive ? status.remaining_seconds : status.duration_min * 60; - const progress = isActive - ? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100 - : 0; + const displaySeconds = isActive ? remainingSeconds : status.duration_min * 60; + const progress = computeProgress(status, remainingSeconds, isActive); const cycle = status.cycle; const cycleLabel = formatCycleLabel(cycle, status.phase, isActive); const ringColor = status.phase === "work" ? "#4f7cff" : "#3dbf8f"; diff --git a/frontend/src/context/PomodoroContext.tsx b/frontend/src/context/PomodoroContext.tsx index 0854a93..fb6b98f 100644 --- a/frontend/src/context/PomodoroContext.tsx +++ b/frontend/src/context/PomodoroContext.tsx @@ -8,28 +8,46 @@ import { useState, } from "react"; import { api, PomodoroStatus } from "../api/client"; +import { computeRemainingSeconds } from "../utils/pomodoroCountdown"; interface PomodoroContextValue { status: PomodoroStatus | null; + /** Остаток секунд для UI — локальный тик при running, snapshot при paused/idle. */ + remainingSeconds: number; error: string | null; refresh: () => Promise; } const PomodoroContext = createContext(null); -const POLL_ACTIVE_MS = 1000; -const POLL_IDLE_MS = 60000; -const POLL_HIDDEN_MS = 120000; +const SYNC_RUNNING_MS = 30000; +const SYNC_PAUSED_MS = 60000; +const SYNC_IDLE_MS = 120000; +const TICK_MS = 1000; -function isTimerActive(status: PomodoroStatus | null): boolean { - return status?.status === "running" || status?.status === "paused"; +function syncIntervalMs(status: PomodoroStatus | null, hidden: boolean): number { + if (hidden) return SYNC_IDLE_MS; + if (status?.status === "running") return SYNC_RUNNING_MS; + if (status?.status === "paused") return SYNC_PAUSED_MS; + return SYNC_IDLE_MS; } export function PomodoroProvider({ children }: { children: ReactNode }) { const [status, setStatus] = useState(null); + const [remainingSeconds, setRemainingSeconds] = useState(0); const [error, setError] = useState(null); const statusRef = useRef(null); + const syncedAtRef = useRef(Date.now()); const refreshInFlight = useRef | null>(null); + const zeroRefreshSent = useRef(false); + + const applyStatus = useCallback((data: PomodoroStatus) => { + statusRef.current = data; + syncedAtRef.current = Date.now(); + zeroRefreshSent.current = false; + setStatus(data); + setRemainingSeconds(computeRemainingSeconds(data, syncedAtRef.current)); + }, []); const refresh = useCallback(async () => { if (refreshInFlight.current) { @@ -39,8 +57,7 @@ export function PomodoroProvider({ children }: { children: ReactNode }) { const task = (async () => { try { const data = await api.pomodoroStatus(); - statusRef.current = data; - setStatus(data); + applyStatus(data); setError(null); } catch (err) { setError(err instanceof Error ? err.message : "Ошибка загрузки таймера"); @@ -51,33 +68,52 @@ export function PomodoroProvider({ children }: { children: ReactNode }) { refreshInFlight.current = task; return task; - }, []); + }, [applyStatus]); + // Локальный тик — без запросов на сервер. + useEffect(() => { + const tick = () => { + const current = statusRef.current; + if (!current || current.status !== "running") return; + + const remaining = computeRemainingSeconds(current, syncedAtRef.current); + setRemainingSeconds(remaining); + + if (remaining <= 0 && !zeroRefreshSent.current) { + zeroRefreshSent.current = true; + refresh().catch(console.error); + } + }; + + tick(); + const id = setInterval(tick, TICK_MS); + return () => clearInterval(id); + }, [refresh, status?.status, status?.session_id]); + + // Редкая синхронизация с сервером (коррекция drift, auto-complete, chat_notify_seq). useEffect(() => { refresh().catch(console.error); let cancelled = false; let timeoutId: ReturnType; - const pollDelay = () => { - if (document.hidden) return POLL_HIDDEN_MS; - return isTimerActive(statusRef.current) ? POLL_ACTIVE_MS : POLL_IDLE_MS; - }; - const schedule = () => { + const delay = syncIntervalMs(statusRef.current, document.hidden); timeoutId = setTimeout(async () => { if (cancelled) return; if (!document.hidden) { await refresh().catch(console.error); } schedule(); - }, pollDelay()); + }, delay); }; const onVisibilityChange = () => { if (!document.hidden) { refresh().catch(console.error); } + clearTimeout(timeoutId); + schedule(); }; document.addEventListener("visibilitychange", onVisibilityChange); @@ -91,7 +127,7 @@ export function PomodoroProvider({ children }: { children: ReactNode }) { }, [refresh]); return ( - + {children} ); diff --git a/frontend/src/pages/Pomodoro.tsx b/frontend/src/pages/Pomodoro.tsx index f2985c2..60cc24b 100644 --- a/frontend/src/pages/Pomodoro.tsx +++ b/frontend/src/pages/Pomodoro.tsx @@ -2,11 +2,12 @@ import { FormEvent, useEffect, useState } from "react"; import { api, PomodoroHistoryItem } from "../api/client"; import { usePomodoro } from "../context/PomodoroContext"; import { formatCycleLabel, phaseLabel } from "../utils/pomodoro"; +import { computeProgress } from "../utils/pomodoroCountdown"; import { formatTime } from "../utils/time"; import "./Pomodoro.css"; export default function Pomodoro() { - const { status, refresh } = usePomodoro(); + const { status, remainingSeconds, refresh } = usePomodoro(); const [history, setHistory] = useState([]); const [duration, setDuration] = useState(25); const [taskNote, setTaskNote] = useState(""); @@ -102,10 +103,9 @@ export default function Pomodoro() { }; const isActive = status?.status === "running" || status?.status === "paused"; - const displaySeconds = isActive ? (status?.remaining_seconds ?? 0) : duration * 60; - const progress = status && isActive - ? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100 - : 0; + const displaySeconds = isActive ? remainingSeconds : duration * 60; + const progress = + status && isActive ? computeProgress(status, remainingSeconds, true) : 0; const cycle = status?.cycle; const cycleLabel = formatCycleLabel(cycle, status?.phase ?? "work", !!isActive); const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f"; diff --git a/frontend/src/utils/pomodoroCountdown.ts b/frontend/src/utils/pomodoroCountdown.ts new file mode 100644 index 0000000..0b6c812 --- /dev/null +++ b/frontend/src/utils/pomodoroCountdown.ts @@ -0,0 +1,28 @@ +import { PomodoroStatus } from "../api/client"; + +/** Локальный остаток от последнего snapshot с сервера. */ +export function computeRemainingSeconds( + status: PomodoroStatus, + syncedAtMs: number, + nowMs: number = Date.now(), +): number { + if (status.status === "paused") { + return status.remaining_seconds; + } + if (status.status === "running") { + const elapsedSinceSync = Math.floor((nowMs - syncedAtMs) / 1000); + return Math.max(0, status.remaining_seconds - elapsedSinceSync); + } + return status.duration_min * 60; +} + +export function computeProgress( + status: PomodoroStatus, + remainingSeconds: number, + isActive: boolean, +): number { + if (!isActive) return 0; + const total = status.duration_min * 60; + if (total <= 0) return 0; + return ((total - remainingSeconds) / total) * 100; +}