added more pomidoro
This commit is contained in:
@@ -18,8 +18,20 @@ export interface SessionDetail extends ChatSession {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
export interface PomodoroCycle {
|
||||
completed_work_sessions: number;
|
||||
sessions_until_long_break: number;
|
||||
task_note: string;
|
||||
work_duration_min: number;
|
||||
short_break_min: number;
|
||||
long_break_min: number;
|
||||
auto_advance: boolean;
|
||||
chat_notify_seq: number;
|
||||
}
|
||||
|
||||
export interface PomodoroStatus {
|
||||
status: string;
|
||||
phase: string;
|
||||
duration_min: number;
|
||||
task_note: string;
|
||||
elapsed_seconds: number;
|
||||
@@ -27,6 +39,7 @@ export interface PomodoroStatus {
|
||||
session_id: number | null;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
cycle: PomodoroCycle;
|
||||
}
|
||||
|
||||
export interface CharacterCardData {
|
||||
@@ -54,6 +67,7 @@ export interface CharacterCardV2 {
|
||||
export interface PomodoroHistoryItem {
|
||||
id: number;
|
||||
status: string;
|
||||
phase: string;
|
||||
duration_min: number;
|
||||
task_note: string;
|
||||
result: string | null;
|
||||
@@ -152,6 +166,26 @@ export const api = {
|
||||
|
||||
pomodoroHistory: () => request<PomodoroHistoryItem[]>("/api/v1/pomodoro/history"),
|
||||
|
||||
pomodoroResetCycle: (clear_task = false) =>
|
||||
request<PomodoroStatus>(`/api/v1/pomodoro/cycle/reset?clear_task=${clear_task}`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
pomodoroSkip: () =>
|
||||
request<PomodoroStatus>("/api/v1/pomodoro/skip", { method: "POST" }),
|
||||
|
||||
pomodoroStartShortBreak: (duration_min?: number) =>
|
||||
request<PomodoroStatus>(
|
||||
`/api/v1/pomodoro/break/short/start${duration_min ? `?duration_min=${duration_min}` : ""}`,
|
||||
{ method: "POST" }
|
||||
),
|
||||
|
||||
pomodoroStartLongBreak: (duration_min?: number) =>
|
||||
request<PomodoroStatus>(
|
||||
`/api/v1/pomodoro/break/long/start${duration_min ? `?duration_min=${duration_min}` : ""}`,
|
||||
{ method: "POST" }
|
||||
),
|
||||
|
||||
getCharacter: () => request<CharacterCardV2>("/api/v1/character"),
|
||||
|
||||
saveCharacter: (card: CharacterCardV2) =>
|
||||
|
||||
@@ -63,8 +63,15 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pomodoro-widget-cycle {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #8b95a5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pomodoro-widget-task {
|
||||
margin: 0.5rem 0 0;
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: #a8b0bd;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { usePomodoro } from "../hooks/usePomodoro";
|
||||
import { formatTime } from "../utils/time";
|
||||
import { phaseLabel } from "../utils/pomodoro";
|
||||
import "./PomodoroWidget.css";
|
||||
|
||||
interface PomodoroWidgetProps {
|
||||
@@ -17,22 +18,31 @@ export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps)
|
||||
const progress = isActive
|
||||
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
|
||||
: 0;
|
||||
const cycle = status.cycle;
|
||||
const ringColor = status.phase === "work" ? "#4f7cff" : "#3dbf8f";
|
||||
|
||||
return (
|
||||
<Link to="/pomodoro" className={`pomodoro-widget ${compact ? "compact" : ""}`}>
|
||||
<div
|
||||
className="pomodoro-widget-ring"
|
||||
style={{ background: `conic-gradient(#4f7cff ${progress}%, #1f2633 0)` }}
|
||||
style={{ background: `conic-gradient(${ringColor} ${progress}%, #1f2633 0)` }}
|
||||
>
|
||||
<div className="pomodoro-widget-inner">
|
||||
<span className="pomodoro-widget-time">{formatTime(displaySeconds)}</span>
|
||||
<span className="pomodoro-widget-label">
|
||||
{status.status === "idle" ? "помидоро" : status.status}
|
||||
{isActive ? phaseLabel(status.phase) : "помидоро"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!compact && status.task_note && (
|
||||
<p className="pomodoro-widget-task">{status.task_note}</p>
|
||||
{!compact && (
|
||||
<>
|
||||
{cycle && (
|
||||
<p className="pomodoro-widget-cycle">
|
||||
Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break}
|
||||
</p>
|
||||
)}
|
||||
{status.task_note && <p className="pomodoro-widget-task">{status.task_note}</p>}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,8 @@ export default function Chat() {
|
||||
const [streaming, setStreaming] = useState("");
|
||||
const [liveNotices, setLiveNotices] = useState<string[]>([]);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { refresh: refreshPomodoro } = usePomodoro();
|
||||
const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro();
|
||||
const [lastNotifySeq, setLastNotifySeq] = useState(0);
|
||||
|
||||
const loadSessions = async () => {
|
||||
const data = await api.listSessions();
|
||||
@@ -55,6 +56,14 @@ export default function Chat() {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streaming, liveNotices]);
|
||||
|
||||
useEffect(() => {
|
||||
const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0;
|
||||
if (seq > lastNotifySeq && activeId) {
|
||||
setLastNotifySeq(seq);
|
||||
loadMessages(activeId).catch(console.error);
|
||||
}
|
||||
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
const session = await api.createSession();
|
||||
await loadSessions();
|
||||
|
||||
@@ -51,14 +51,40 @@
|
||||
color: #c5ccd6;
|
||||
}
|
||||
|
||||
.cycle-badge {
|
||||
text-align: center;
|
||||
color: #8b95a5;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.timer-form,
|
||||
.timer-controls,
|
||||
.stop-form {
|
||||
.stop-form,
|
||||
.timer-form-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.timer-form-actions button {
|
||||
background: #2b3445;
|
||||
color: inherit;
|
||||
border: 1px solid #3a4558;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.85rem;
|
||||
}
|
||||
|
||||
.reset-cycle-btn {
|
||||
margin-top: 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid #4a3540;
|
||||
color: #c58a8a;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.85rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timer-form label,
|
||||
.stop-form label {
|
||||
display: flex;
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client";
|
||||
import { phaseLabel } from "../utils/pomodoro";
|
||||
import { formatTime } from "../utils/time";
|
||||
import "./Pomodoro.css";
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function Pomodoro() {
|
||||
const [status, setStatus] = useState<PomodoroStatus | null>(null);
|
||||
const [history, setHistory] = useState<PomodoroHistoryItem[]>([]);
|
||||
@@ -21,6 +17,12 @@ export default function Pomodoro() {
|
||||
const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]);
|
||||
setStatus(current);
|
||||
setHistory(past);
|
||||
if (current.cycle?.work_duration_min) {
|
||||
setDuration(current.cycle.work_duration_min);
|
||||
}
|
||||
if (current.cycle?.task_note) {
|
||||
setTaskNote(current.cycle.task_note);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,7 +33,7 @@ export default function Pomodoro() {
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const handleStart = async (e: FormEvent) => {
|
||||
const handleStartWork = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
@@ -67,7 +69,6 @@ export default function Pomodoro() {
|
||||
try {
|
||||
await api.pomodoroStop(result, completed);
|
||||
await refresh();
|
||||
setTaskNote("");
|
||||
setResult("");
|
||||
setCompleted(false);
|
||||
} catch (err) {
|
||||
@@ -75,28 +76,62 @@ export default function Pomodoro() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
setError("");
|
||||
try {
|
||||
await api.pomodoroSkip();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setError("");
|
||||
try {
|
||||
await api.pomodoroResetCycle(false);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = status?.status === "running" || status?.status === "paused";
|
||||
const displaySeconds = isActive ? (status?.remaining_seconds ?? 0) : duration * 60;
|
||||
const progress = status
|
||||
const progress = status && isActive
|
||||
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
|
||||
: 0;
|
||||
const cycle = status?.cycle;
|
||||
const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f";
|
||||
|
||||
return (
|
||||
<div className="pomodoro-page">
|
||||
<section className="timer-card">
|
||||
<div className="timer-ring" style={{ background: `conic-gradient(#4f7cff ${progress}%, #1f2633 0)` }}>
|
||||
{cycle && (
|
||||
<div className="cycle-badge">
|
||||
Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break}
|
||||
{cycle.auto_advance && " · авто"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="timer-ring"
|
||||
style={{ background: `conic-gradient(${ringColor} ${progress}%, #1f2633 0)` }}
|
||||
>
|
||||
<div className="timer-inner">
|
||||
<div className="timer-value">{formatTime(displaySeconds)}</div>
|
||||
<div className="timer-status">{status?.status ?? "idle"}</div>
|
||||
<div className="timer-status">
|
||||
{isActive ? phaseLabel(status?.phase ?? "work") : status?.status ?? "idle"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status?.task_note && <p className="task-note">Задача: {status.task_note}</p>}
|
||||
|
||||
{!isActive ? (
|
||||
<form className="timer-form" onSubmit={handleStart}>
|
||||
<form className="timer-form" onSubmit={handleStartWork}>
|
||||
<label>
|
||||
Минут
|
||||
Работа (мин)
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -113,9 +148,17 @@ export default function Pomodoro() {
|
||||
placeholder="Опишите задачу"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="primary-btn">
|
||||
Старт
|
||||
</button>
|
||||
<div className="timer-form-actions">
|
||||
<button type="submit" className="primary-btn">
|
||||
Старт работы
|
||||
</button>
|
||||
<button type="button" onClick={() => api.pomodoroStartShortBreak().then(setStatus)}>
|
||||
Короткий перерыв
|
||||
</button>
|
||||
<button type="button" onClick={() => api.pomodoroStartLongBreak().then(setStatus)}>
|
||||
Длинный перерыв
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="timer-controls">
|
||||
@@ -124,6 +167,7 @@ export default function Pomodoro() {
|
||||
) : (
|
||||
<button onClick={handleResume}>Продолжить</button>
|
||||
)}
|
||||
<button onClick={handleSkip}>Пропустить фазу</button>
|
||||
<div className="stop-form">
|
||||
<input
|
||||
value={result}
|
||||
@@ -136,13 +180,17 @@ export default function Pomodoro() {
|
||||
checked={completed}
|
||||
onChange={(e) => setCompleted(e.target.checked)}
|
||||
/>
|
||||
Задача завершена
|
||||
Фаза завершена
|
||||
</label>
|
||||
<button onClick={handleStop}>Стоп</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" className="reset-cycle-btn" onClick={handleReset}>
|
||||
Сбросить цикл
|
||||
</button>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
</section>
|
||||
|
||||
@@ -152,10 +200,11 @@ export default function Pomodoro() {
|
||||
{history.map((item) => (
|
||||
<li key={item.id}>
|
||||
<div className="history-title">
|
||||
{item.task_note || "Без описания"} — {item.status}
|
||||
{phaseLabel(item.phase)}: {item.task_note || "Без описания"} — {item.status}
|
||||
</div>
|
||||
<div className="history-meta">
|
||||
{item.duration_min} мин · {item.finished_at ? new Date(item.finished_at).toLocaleString("ru-RU") : ""}
|
||||
{item.duration_min} мин ·{" "}
|
||||
{item.finished_at ? new Date(item.finished_at).toLocaleString("ru-RU") : ""}
|
||||
</div>
|
||||
{item.result && <div className="history-result">{item.result}</div>}
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const PHASE_LABELS: Record<string, string> = {
|
||||
work: "Работа",
|
||||
short_break: "Перерыв",
|
||||
long_break: "Длинный перерыв",
|
||||
};
|
||||
|
||||
export function phaseLabel(phase: string): string {
|
||||
return PHASE_LABELS[phase] ?? phase;
|
||||
}
|
||||
Reference in New Issue
Block a user