fixed timer

This commit is contained in:
2026-06-11 08:48:21 +03:00
parent 0ccf19a1cc
commit 54ed9ba791
4 changed files with 88 additions and 25 deletions
+4 -5
View File
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
import { usePomodoro } from "../context/PomodoroContext"; import { usePomodoro } from "../context/PomodoroContext";
import { formatTime } from "../utils/time"; import { formatTime } from "../utils/time";
import { formatCycleLabel, phaseLabel } from "../utils/pomodoro"; import { formatCycleLabel, phaseLabel } from "../utils/pomodoro";
import { computeProgress } from "../utils/pomodoroCountdown";
import "./PomodoroWidget.css"; import "./PomodoroWidget.css";
interface PomodoroWidgetProps { interface PomodoroWidgetProps {
@@ -9,15 +10,13 @@ interface PomodoroWidgetProps {
} }
export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps) { export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps) {
const { status } = usePomodoro(); const { status, remainingSeconds } = usePomodoro();
if (!status) return null; if (!status) return null;
const isActive = status.status === "running" || status.status === "paused"; const isActive = status.status === "running" || status.status === "paused";
const displaySeconds = isActive ? status.remaining_seconds : status.duration_min * 60; const displaySeconds = isActive ? remainingSeconds : status.duration_min * 60;
const progress = isActive const progress = computeProgress(status, remainingSeconds, isActive);
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
: 0;
const cycle = status.cycle; const cycle = status.cycle;
const cycleLabel = formatCycleLabel(cycle, status.phase, isActive); const cycleLabel = formatCycleLabel(cycle, status.phase, isActive);
const ringColor = status.phase === "work" ? "#4f7cff" : "#3dbf8f"; const ringColor = status.phase === "work" ? "#4f7cff" : "#3dbf8f";
+51 -15
View File
@@ -8,28 +8,46 @@ import {
useState, useState,
} from "react"; } from "react";
import { api, PomodoroStatus } from "../api/client"; import { api, PomodoroStatus } from "../api/client";
import { computeRemainingSeconds } from "../utils/pomodoroCountdown";
interface PomodoroContextValue { interface PomodoroContextValue {
status: PomodoroStatus | null; status: PomodoroStatus | null;
/** Остаток секунд для UI — локальный тик при running, snapshot при paused/idle. */
remainingSeconds: number;
error: string | null; error: string | null;
refresh: () => Promise<void>; refresh: () => Promise<void>;
} }
const PomodoroContext = createContext<PomodoroContextValue | null>(null); const PomodoroContext = createContext<PomodoroContextValue | null>(null);
const POLL_ACTIVE_MS = 1000; const SYNC_RUNNING_MS = 30000;
const POLL_IDLE_MS = 60000; const SYNC_PAUSED_MS = 60000;
const POLL_HIDDEN_MS = 120000; const SYNC_IDLE_MS = 120000;
const TICK_MS = 1000;
function isTimerActive(status: PomodoroStatus | null): boolean { function syncIntervalMs(status: PomodoroStatus | null, hidden: boolean): number {
return status?.status === "running" || status?.status === "paused"; 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 }) { export function PomodoroProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<PomodoroStatus | null>(null); const [status, setStatus] = useState<PomodoroStatus | null>(null);
const [remainingSeconds, setRemainingSeconds] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const statusRef = useRef<PomodoroStatus | null>(null); const statusRef = useRef<PomodoroStatus | null>(null);
const syncedAtRef = useRef(Date.now());
const refreshInFlight = useRef<Promise<void> | null>(null); const refreshInFlight = useRef<Promise<void> | 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 () => { const refresh = useCallback(async () => {
if (refreshInFlight.current) { if (refreshInFlight.current) {
@@ -39,8 +57,7 @@ export function PomodoroProvider({ children }: { children: ReactNode }) {
const task = (async () => { const task = (async () => {
try { try {
const data = await api.pomodoroStatus(); const data = await api.pomodoroStatus();
statusRef.current = data; applyStatus(data);
setStatus(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки таймера"); setError(err instanceof Error ? err.message : "Ошибка загрузки таймера");
@@ -51,33 +68,52 @@ export function PomodoroProvider({ children }: { children: ReactNode }) {
refreshInFlight.current = task; refreshInFlight.current = task;
return 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(() => { useEffect(() => {
refresh().catch(console.error); refresh().catch(console.error);
let cancelled = false; let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout>; let timeoutId: ReturnType<typeof setTimeout>;
const pollDelay = () => {
if (document.hidden) return POLL_HIDDEN_MS;
return isTimerActive(statusRef.current) ? POLL_ACTIVE_MS : POLL_IDLE_MS;
};
const schedule = () => { const schedule = () => {
const delay = syncIntervalMs(statusRef.current, document.hidden);
timeoutId = setTimeout(async () => { timeoutId = setTimeout(async () => {
if (cancelled) return; if (cancelled) return;
if (!document.hidden) { if (!document.hidden) {
await refresh().catch(console.error); await refresh().catch(console.error);
} }
schedule(); schedule();
}, pollDelay()); }, delay);
}; };
const onVisibilityChange = () => { const onVisibilityChange = () => {
if (!document.hidden) { if (!document.hidden) {
refresh().catch(console.error); refresh().catch(console.error);
} }
clearTimeout(timeoutId);
schedule();
}; };
document.addEventListener("visibilitychange", onVisibilityChange); document.addEventListener("visibilitychange", onVisibilityChange);
@@ -91,7 +127,7 @@ export function PomodoroProvider({ children }: { children: ReactNode }) {
}, [refresh]); }, [refresh]);
return ( return (
<PomodoroContext.Provider value={{ status, error, refresh }}> <PomodoroContext.Provider value={{ status, remainingSeconds, error, refresh }}>
{children} {children}
</PomodoroContext.Provider> </PomodoroContext.Provider>
); );
+5 -5
View File
@@ -2,11 +2,12 @@ import { FormEvent, useEffect, useState } from "react";
import { api, PomodoroHistoryItem } from "../api/client"; import { api, PomodoroHistoryItem } from "../api/client";
import { usePomodoro } from "../context/PomodoroContext"; import { usePomodoro } from "../context/PomodoroContext";
import { formatCycleLabel, phaseLabel } from "../utils/pomodoro"; import { formatCycleLabel, phaseLabel } from "../utils/pomodoro";
import { computeProgress } from "../utils/pomodoroCountdown";
import { formatTime } from "../utils/time"; import { formatTime } from "../utils/time";
import "./Pomodoro.css"; import "./Pomodoro.css";
export default function Pomodoro() { export default function Pomodoro() {
const { status, refresh } = usePomodoro(); const { status, remainingSeconds, refresh } = usePomodoro();
const [history, setHistory] = useState<PomodoroHistoryItem[]>([]); const [history, setHistory] = useState<PomodoroHistoryItem[]>([]);
const [duration, setDuration] = useState(25); const [duration, setDuration] = useState(25);
const [taskNote, setTaskNote] = useState(""); const [taskNote, setTaskNote] = useState("");
@@ -102,10 +103,9 @@ export default function Pomodoro() {
}; };
const isActive = status?.status === "running" || status?.status === "paused"; const isActive = status?.status === "running" || status?.status === "paused";
const displaySeconds = isActive ? (status?.remaining_seconds ?? 0) : duration * 60; const displaySeconds = isActive ? remainingSeconds : duration * 60;
const progress = status && isActive const progress =
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100 status && isActive ? computeProgress(status, remainingSeconds, true) : 0;
: 0;
const cycle = status?.cycle; const cycle = status?.cycle;
const cycleLabel = formatCycleLabel(cycle, status?.phase ?? "work", !!isActive); const cycleLabel = formatCycleLabel(cycle, status?.phase ?? "work", !!isActive);
const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f"; const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f";
+28
View File
@@ -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;
}