Taiga integration
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { NavLink, Route, Routes } from "react-router-dom";
|
||||
import PomodoroWidget from "./components/PomodoroWidget";
|
||||
import { PomodoroProvider } from "./context/PomodoroContext";
|
||||
import Character from "./pages/Character";
|
||||
import Chat from "./pages/Chat";
|
||||
import Pomodoro from "./pages/Pomodoro";
|
||||
@@ -7,6 +8,7 @@ import "./App.css";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<PomodoroProvider>
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Home AI Assistant</h1>
|
||||
@@ -27,5 +29,6 @@ export default function App() {
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</PomodoroProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,22 +9,43 @@
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact {
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.pomodoro-widget:hover {
|
||||
border-color: #4f7cff;
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact:hover {
|
||||
border-color: transparent;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.pomodoro-widget-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact .pomodoro-widget-body {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.pomodoro-widget-ring {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact .pomodoro-widget-ring {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.pomodoro-widget-inner {
|
||||
@@ -39,8 +60,8 @@
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact .pomodoro-widget-inner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.pomodoro-widget-time {
|
||||
@@ -50,7 +71,7 @@
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact .pomodoro-widget-time {
|
||||
font-size: 0.55rem;
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.pomodoro-widget-label {
|
||||
@@ -64,14 +85,20 @@
|
||||
}
|
||||
|
||||
.pomodoro-widget-cycle {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #c5ccd6;
|
||||
white-space: nowrap;
|
||||
min-width: 2.5rem;
|
||||
}
|
||||
|
||||
.pomodoro-widget.compact .pomodoro-widget-cycle {
|
||||
font-size: 0.75rem;
|
||||
color: #8b95a5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pomodoro-widget-task {
|
||||
margin: 0.25rem 0 0;
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: #a8b0bd;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { usePomodoro } from "../hooks/usePomodoro";
|
||||
import { usePomodoro } from "../context/PomodoroContext";
|
||||
import { formatTime } from "../utils/time";
|
||||
import { phaseLabel } from "../utils/pomodoro";
|
||||
import { formatCycleLabel, phaseLabel } from "../utils/pomodoro";
|
||||
import "./PomodoroWidget.css";
|
||||
|
||||
interface PomodoroWidgetProps {
|
||||
@@ -19,30 +19,31 @@ export default function PomodoroWidget({ compact = false }: PomodoroWidgetProps)
|
||||
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
|
||||
: 0;
|
||||
const cycle = status.cycle;
|
||||
const cycleLabel = formatCycleLabel(cycle, status.phase, isActive);
|
||||
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(${ringColor} ${progress}%, #1f2633 0)` }}
|
||||
>
|
||||
<div className="pomodoro-widget-inner">
|
||||
<span className="pomodoro-widget-time">{formatTime(displaySeconds)}</span>
|
||||
<span className="pomodoro-widget-label">
|
||||
{isActive ? phaseLabel(status.phase) : "помидоро"}
|
||||
</span>
|
||||
<div className="pomodoro-widget-body">
|
||||
<div
|
||||
className="pomodoro-widget-ring"
|
||||
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">
|
||||
{isActive ? phaseLabel(status.phase) : "помидоро"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="pomodoro-widget-cycle" title="Прогресс цикла">
|
||||
{cycleLabel}
|
||||
</span>
|
||||
</div>
|
||||
{!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>}
|
||||
</>
|
||||
|
||||
{!compact && status.task_note && (
|
||||
<p className="pomodoro-widget-task">{status.task_note}</p>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { api, PomodoroStatus } from "../api/client";
|
||||
|
||||
interface PomodoroContextValue {
|
||||
status: PomodoroStatus | null;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PomodoroContext = createContext<PomodoroContextValue | null>(null);
|
||||
|
||||
export function PomodoroProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<PomodoroStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.pomodoroStatus();
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка загрузки таймера");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(console.error);
|
||||
const timer = setInterval(() => {
|
||||
refresh().catch(console.error);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
return (
|
||||
<PomodoroContext.Provider value={{ status, error, refresh }}>
|
||||
{children}
|
||||
</PomodoroContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePomodoro() {
|
||||
const ctx = useContext(PomodoroContext);
|
||||
if (!ctx) {
|
||||
throw new Error("usePomodoro must be used within PomodoroProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, PomodoroStatus } from "../api/client";
|
||||
|
||||
export function usePomodoro(pollMs = 1000) {
|
||||
const [status, setStatus] = useState<PomodoroStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.pomodoroStatus();
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка загрузки таймера");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(console.error);
|
||||
const timer = setInterval(() => {
|
||||
refresh().catch(console.error);
|
||||
}, pollMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh, pollMs]);
|
||||
|
||||
return { status, error, refresh };
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { api, ChatMessage, ChatSession } from "../api/client";
|
||||
import PomodoroWidget from "../components/PomodoroWidget";
|
||||
import { usePomodoro } from "../hooks/usePomodoro";
|
||||
import { usePomodoro } from "../context/PomodoroContext";
|
||||
import "./Chat.css";
|
||||
|
||||
function shouldShowMessage(msg: ChatMessage): boolean {
|
||||
@@ -58,11 +58,14 @@ export default function Chat() {
|
||||
|
||||
useEffect(() => {
|
||||
const seq = pomodoroStatus?.cycle?.chat_notify_seq ?? 0;
|
||||
if (seq > lastNotifySeq && activeId) {
|
||||
if (seq > lastNotifySeq) {
|
||||
setLastNotifySeq(seq);
|
||||
loadMessages(activeId).catch(console.error);
|
||||
refreshPomodoro().catch(console.error);
|
||||
if (activeId) {
|
||||
loadMessages(activeId).catch(console.error);
|
||||
}
|
||||
}
|
||||
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq]);
|
||||
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
const session = await api.createSession();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { api, PomodoroHistoryItem, PomodoroStatus } from "../api/client";
|
||||
import { phaseLabel } from "../utils/pomodoro";
|
||||
import { api, PomodoroHistoryItem } from "../api/client";
|
||||
import { usePomodoro } from "../context/PomodoroContext";
|
||||
import { formatCycleLabel, phaseLabel } from "../utils/pomodoro";
|
||||
import { formatTime } from "../utils/time";
|
||||
import "./Pomodoro.css";
|
||||
|
||||
export default function Pomodoro() {
|
||||
const [status, setStatus] = useState<PomodoroStatus | null>(null);
|
||||
const { status, refresh } = usePomodoro();
|
||||
const [history, setHistory] = useState<PomodoroHistoryItem[]>([]);
|
||||
const [duration, setDuration] = useState(25);
|
||||
const [taskNote, setTaskNote] = useState("");
|
||||
@@ -13,32 +14,31 @@ export default function Pomodoro() {
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = async () => {
|
||||
const [current, past] = await Promise.all([api.pomodoroStatus(), api.pomodoroHistory()]);
|
||||
setStatus(current);
|
||||
const loadHistory = async () => {
|
||||
const past = await api.pomodoroHistory();
|
||||
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(() => {
|
||||
refresh().catch(console.error);
|
||||
const timer = setInterval(() => {
|
||||
api.pomodoroStatus().then(setStatus).catch(console.error);
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
loadHistory().catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.cycle?.work_duration_min) {
|
||||
setDuration(status.cycle.work_duration_min);
|
||||
}
|
||||
if (status?.cycle?.task_note) {
|
||||
setTaskNote(status.cycle.task_note);
|
||||
}
|
||||
}, [status?.cycle?.work_duration_min, status?.cycle?.task_note]);
|
||||
|
||||
const handleStartWork = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
const data = await api.pomodoroStart(duration, taskNote);
|
||||
setStatus(data);
|
||||
await api.pomodoroStart(duration, taskNote);
|
||||
await refresh();
|
||||
await loadHistory();
|
||||
setResult("");
|
||||
setCompleted(false);
|
||||
} catch (err) {
|
||||
@@ -49,7 +49,8 @@ export default function Pomodoro() {
|
||||
const handlePause = async () => {
|
||||
setError("");
|
||||
try {
|
||||
setStatus(await api.pomodoroPause());
|
||||
await api.pomodoroPause();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
@@ -58,7 +59,8 @@ export default function Pomodoro() {
|
||||
const handleResume = async () => {
|
||||
setError("");
|
||||
try {
|
||||
setStatus(await api.pomodoroResume());
|
||||
await api.pomodoroResume();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
@@ -69,6 +71,7 @@ export default function Pomodoro() {
|
||||
try {
|
||||
await api.pomodoroStop(result, completed);
|
||||
await refresh();
|
||||
await loadHistory();
|
||||
setResult("");
|
||||
setCompleted(false);
|
||||
} catch (err) {
|
||||
@@ -81,6 +84,7 @@ export default function Pomodoro() {
|
||||
try {
|
||||
await api.pomodoroSkip();
|
||||
await refresh();
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
@@ -91,6 +95,7 @@ export default function Pomodoro() {
|
||||
try {
|
||||
await api.pomodoroResetCycle(false);
|
||||
await refresh();
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Ошибка");
|
||||
}
|
||||
@@ -102,6 +107,7 @@ export default function Pomodoro() {
|
||||
? ((status.duration_min * 60 - status.remaining_seconds) / (status.duration_min * 60)) * 100
|
||||
: 0;
|
||||
const cycle = status?.cycle;
|
||||
const cycleLabel = formatCycleLabel(cycle, status?.phase ?? "work", !!isActive);
|
||||
const ringColor = status?.phase === "work" ? "#4f7cff" : "#3dbf8f";
|
||||
|
||||
return (
|
||||
@@ -109,7 +115,7 @@ export default function Pomodoro() {
|
||||
<section className="timer-card">
|
||||
{cycle && (
|
||||
<div className="cycle-badge">
|
||||
Цикл {cycle.completed_work_sessions}/{cycle.sessions_until_long_break}
|
||||
Цикл {cycleLabel}
|
||||
{cycle.auto_advance && " · авто"}
|
||||
</div>
|
||||
)}
|
||||
@@ -152,10 +158,16 @@ export default function Pomodoro() {
|
||||
<button type="submit" className="primary-btn">
|
||||
Старт работы
|
||||
</button>
|
||||
<button type="button" onClick={() => api.pomodoroStartShortBreak().then(setStatus)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => api.pomodoroStartShortBreak().then(() => refresh())}
|
||||
>
|
||||
Короткий перерыв
|
||||
</button>
|
||||
<button type="button" onClick={() => api.pomodoroStartLongBreak().then(setStatus)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => api.pomodoroStartLongBreak().then(() => refresh())}
|
||||
>
|
||||
Длинный перерыв
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PomodoroCycle } from "../api/client";
|
||||
|
||||
export const PHASE_LABELS: Record<string, string> = {
|
||||
work: "Работа",
|
||||
short_break: "Перерыв",
|
||||
@@ -7,3 +9,28 @@ export const PHASE_LABELS: Record<string, string> = {
|
||||
export function phaseLabel(phase: string): string {
|
||||
return PHASE_LABELS[phase] ?? phase;
|
||||
}
|
||||
|
||||
/** Текущий номер помидоро в цикле (1..N), а не только завершённые. */
|
||||
export function cycleProgress(
|
||||
cycle: PomodoroCycle | undefined,
|
||||
phase: string,
|
||||
isActive: boolean
|
||||
): { current: number; total: number } {
|
||||
const total = cycle?.sessions_until_long_break ?? 4;
|
||||
let current = cycle?.completed_work_sessions ?? 0;
|
||||
|
||||
if (isActive && phase === "work") {
|
||||
current += 1;
|
||||
}
|
||||
|
||||
return { current, total };
|
||||
}
|
||||
|
||||
export function formatCycleLabel(
|
||||
cycle: PomodoroCycle | undefined,
|
||||
phase: string,
|
||||
isActive: boolean
|
||||
): string {
|
||||
const { current, total } = cycleProgress(cycle, phase, isActive);
|
||||
return `${current}/${total}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user