feat(CC-089): add Focus Session page (Pomodoro SVG timer, task picker, auto-log observations)

This commit is contained in:
admin-valentin 2026-08-02 17:51:20 +00:00
parent 5ad40653ab
commit c023de5c1f

View 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>
);
}