fixed timer
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
const PomodoroContext = createContext<PomodoroContextValue | null>(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<PomodoroStatus | null>(null);
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const statusRef = useRef<PomodoroStatus | null>(null);
|
||||
const syncedAtRef = useRef(Date.now());
|
||||
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 () => {
|
||||
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<typeof setTimeout>;
|
||||
|
||||
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 (
|
||||
<PomodoroContext.Provider value={{ status, error, refresh }}>
|
||||
<PomodoroContext.Provider value={{ status, remainingSeconds, error, refresh }}>
|
||||
{children}
|
||||
</PomodoroContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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<PomodoroHistoryItem[]>([]);
|
||||
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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user