feat(CC-089): add Focus Session page (Pomodoro SVG timer, task picker, auto-log observations)
This commit is contained in:
parent
5ad40653ab
commit
c023de5c1f
1 changed files with 178 additions and 0 deletions
178
src/app/dashboard/focus/page.tsx
Normal file
178
src/app/dashboard/focus/page.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface Task { id: string; title: string; status: string; priority: string | null; tags: string[]; }
|
||||
|
||||
const PRESETS = [
|
||||
{ label: '25 min', work: 25, break: 5 },
|
||||
{ label: '50 min', work: 50, break: 10 },
|
||||
{ label: '90 min', work: 90, break: 15 },
|
||||
];
|
||||
|
||||
export default function FocusSessionPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [preset, setPreset] = useState(PRESETS[0]);
|
||||
const [mode, setMode] = useState<'idle' | 'work' | 'break'>('idle');
|
||||
const [secsLeft, setSecsLeft] = useState(preset.work * 60);
|
||||
const [selectedTask, setSelectedTask] = useState('');
|
||||
const [completedSessions, setCompletedSessions] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const { data: tasks = [] } = useQuery({
|
||||
queryKey: ['focus-tasks', tenantId],
|
||||
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=200', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const activeTasks = tasks.filter((t) => !['completed', 'cancelled'].includes(t.status));
|
||||
const urgentTasks = activeTasks.filter((t) => t.priority === 'urgent' || t.priority === 'high');
|
||||
|
||||
const logMut = useMutation({
|
||||
mutationFn: (taskTitle: string) => apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
|
||||
metric: 'focus-session', value: `${preset.work}`, unit: 'min',
|
||||
subjectType: 'productivity', confidence: 1,
|
||||
source: taskTitle || undefined,
|
||||
observedAt: new Date().toISOString(),
|
||||
}}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['focus-obs', tenantId] }),
|
||||
});
|
||||
|
||||
const start = useCallback(() => {
|
||||
setMode('work');
|
||||
setSecsLeft(preset.work * 60);
|
||||
}, [preset]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
setMode('idle');
|
||||
setSecsLeft(preset.work * 60);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'idle') { if (intervalRef.current) clearInterval(intervalRef.current); return; }
|
||||
intervalRef.current = setInterval(() => {
|
||||
setSecsLeft((s) => {
|
||||
if (s <= 1) {
|
||||
if (mode === 'work') {
|
||||
setCompletedSessions((c) => c + 1);
|
||||
logMut.mutate(selectedTask);
|
||||
setMode('break');
|
||||
return preset.break * 60;
|
||||
} else {
|
||||
setMode('idle');
|
||||
return preset.work * 60;
|
||||
}
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
||||
}, [mode, preset, selectedTask]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'idle') setSecsLeft(preset.work * 60);
|
||||
}, [preset]);
|
||||
|
||||
const mins = Math.floor(secsLeft / 60).toString().padStart(2, '0');
|
||||
const secs = (secsLeft % 60).toString().padStart(2, '0');
|
||||
const totalSecs = (mode === 'work' ? preset.work : preset.break) * 60;
|
||||
const progressPct = mode === 'idle' ? 0 : ((totalSecs - secsLeft) / totalSecs) * 100;
|
||||
|
||||
const r = 90;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const dash = circ * (1 - progressPct / 100);
|
||||
|
||||
return (
|
||||
<div className="max-w-xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Focus Session</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">Timer de lucru focalizat cu logare automată.</p>
|
||||
</div>
|
||||
|
||||
{/* Preset selection */}
|
||||
<div className="flex gap-2">
|
||||
{PRESETS.map((p) => (
|
||||
<button key={p.label} onClick={() => { setPreset(p); stop(); }}
|
||||
disabled={mode !== 'idle'}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium border transition-colors disabled:opacity-50 ${preset.label === p.label ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Task picker */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-ink-faint">Task pe care lucrezi (opțional)</label>
|
||||
<select value={selectedTask} onChange={(e) => setSelectedTask(e.target.value)} disabled={mode !== 'idle'}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60">
|
||||
<option value="">— Fără task specific —</option>
|
||||
{urgentTasks.length > 0 && (
|
||||
<optgroup label="Urgente / High">
|
||||
{urgentTasks.map((t) => <option key={t.id} value={t.title}>{t.title}</option>)}
|
||||
</optgroup>
|
||||
)}
|
||||
<optgroup label="Toate taskurile active">
|
||||
{activeTasks.filter((t) => t.priority !== 'urgent' && t.priority !== 'high').slice(0, 20).map((t) => (
|
||||
<option key={t.id} value={t.title}>{t.title}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Timer circle */}
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative">
|
||||
<svg width="220" height="220" className="-rotate-90">
|
||||
<circle cx="110" cy="110" r={r} fill="none" strokeWidth="8" className="stroke-muted" />
|
||||
<circle cx="110" cy="110" r={r} fill="none" strokeWidth="8"
|
||||
className={mode === 'break' ? 'stroke-signal-ok' : 'stroke-primary'}
|
||||
strokeDasharray={circ} strokeDashoffset={dash}
|
||||
style={{ transition: 'stroke-dashoffset 1s linear' }} />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p className="font-display text-5xl font-bold text-ink tabular-nums">{mins}:{secs}</p>
|
||||
<p className="text-xs text-ink-faint mt-1 capitalize">
|
||||
{mode === 'idle' ? 'Ready' : mode === 'work' ? 'Focus' : 'Pauză ☕'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{mode === 'idle' ? (
|
||||
<button onClick={start}
|
||||
className="rounded-xl bg-primary px-8 py-3 text-sm font-semibold text-white hover:bg-primary/90">
|
||||
▶ Start
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={stop}
|
||||
className="rounded-xl border border-signal-danger px-8 py-3 text-sm font-semibold text-signal-danger hover:bg-signal-danger/10">
|
||||
■ Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{completedSessions > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{Array.from({ length: completedSessions }, (_, i) => (
|
||||
<span key={i} className="text-xl">🍅</span>
|
||||
))}
|
||||
<p className="text-xs text-ink-faint">{completedSessions} sesiune{completedSessions > 1 ? 'i' : ''} azi</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-3 text-center bg-primary/5 border-primary/20">
|
||||
<p className="text-[10px] text-ink-faint">
|
||||
Sesiunile completate se loghează automat ca observații (<code>focus-session</code>) în CEO OS.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue