fixed timer
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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