feat(CC-089): add Daily Standup page (yesterday/today/blockers/mood -> saved as observations)

This commit is contained in:
admin-valentin 2026-08-02 17:51:17 +00:00
parent 25a27dd9d7
commit 7a30bbf1ac

View file

@ -0,0 +1,163 @@
'use client';
import { useMemo, useState } 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[]; updatedAt: string; createdAt: string; }
interface Observation { id: string; metric: string; value: string; createdAt: string; }
const TODAY = new Date().toISOString().slice(0, 10);
export default function StandupPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [yesterday, setYesterday] = useState('');
const [today, setToday] = useState('');
const [blockers, setBlockers] = useState('');
const [mood, setMood] = useState(3);
const [submitted, setSubmitted] = useState(false);
const { data: tasks = [] } = useQuery({ queryKey: ['standup-tasks', tenantId], queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=200', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const { data: observations = [] } = useQuery({ queryKey: ['standup-obs', tenantId], queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
const completedToday = useMemo(() =>
tasks.filter((t) => t.status === 'completed' && t.updatedAt.startsWith(TODAY)),
[tasks]);
const todayObs = useMemo(() =>
observations.filter((o) => o.createdAt.startsWith(TODAY)),
[observations]);
const upcoming = useMemo(() =>
tasks.filter((t) => !['completed','cancelled'].includes(t.status) && t.priority === 'urgent').slice(0, 5),
[tasks]);
const submitMut = useMutation({
mutationFn: async () => {
const promises = [];
if (yesterday.trim()) {
promises.push(apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
metric: 'standup-yesterday', value: yesterday.trim(),
subjectType: 'daily-log', confidence: 1, observedAt: new Date().toISOString(),
}}));
}
if (today.trim()) {
promises.push(apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
metric: 'standup-today', value: today.trim(),
subjectType: 'daily-log', confidence: 1, observedAt: new Date().toISOString(),
}}));
}
if (blockers.trim()) {
promises.push(apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
metric: 'standup-blockers', value: blockers.trim(),
subjectType: 'daily-log', confidence: 1, observedAt: new Date().toISOString(),
}}));
}
promises.push(apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
metric: 'energy-level', value: String(mood),
unit: '/5', subjectType: 'personal', confidence: 1, observedAt: new Date().toISOString(),
}}));
await Promise.all(promises);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['standup-obs', tenantId] });
setSubmitted(true);
},
});
const dateLabel = new Date().toLocaleDateString('ro-RO', { weekday: 'long', day: 'numeric', month: 'long' });
const MOODS = ['😴', '😐', '🙂', '😊', '🚀'];
return (
<div className="max-w-2xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Daily Standup</h1>
<p className="text-sm text-ink-faint mt-1 capitalize">{dateLabel}</p>
</div>
{submitted ? (
<div className="card p-8 text-center space-y-3">
<p className="text-4xl"></p>
<p className="text-lg font-semibold text-ink">Standup salvat!</p>
<p className="text-sm text-ink-faint">Răspunsurile au fost logate ca observații în CEO OS.</p>
<button onClick={() => { setSubmitted(false); setYesterday(''); setToday(''); setBlockers(''); setMood(3); }}
className="rounded-lg border px-4 py-2 text-sm text-ink hover:bg-muted/50">
Nou standup
</button>
</div>
) : (
<>
{/* Context from CEO OS */}
{(completedToday.length > 0 || todayObs.length > 0) && (
<div className="card p-4 space-y-2 bg-primary/5 border-primary/20">
<p className="text-xs font-semibold text-ink">Din CEO OS azi</p>
{completedToday.length > 0 && <p className="text-xs text-signal-ok"> {completedToday.length} task-uri finalizate astăzi</p>}
{todayObs.length > 0 && <p className="text-xs text-ink-faint">📊 {todayObs.length} observații logate</p>}
</div>
)}
{/* Standup form */}
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-ink">Ce ai realizat ieri?</label>
<textarea value={yesterday} onChange={(e) => setYesterday(e.target.value)}
placeholder="Descrie pe scurt ce ai terminat sau avansat…"
rows={3} 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 resize-none" />
{completedToday.length > 0 && (
<div className="text-[10px] text-ink-faint space-y-0.5">
{completedToday.slice(0, 3).map((t) => (
<button key={t.id} onClick={() => setYesterday((p) => p ? p + '\n• ' + t.title : '• ' + t.title)}
className="block text-left hover:text-primary">+ {t.title}</button>
))}
</div>
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-ink">Ce vei face azi?</label>
<textarea value={today} onChange={(e) => setToday(e.target.value)}
placeholder="Prioritățile pentru ziua de azi…"
rows={3} 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 resize-none" />
{upcoming.length > 0 && (
<div className="text-[10px] text-ink-faint space-y-0.5">
{upcoming.slice(0, 3).map((t) => (
<button key={t.id} onClick={() => setToday((p) => p ? p + '\n• ' + t.title : '• ' + t.title)}
className="block text-left hover:text-primary">+ {t.title}</button>
))}
</div>
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-ink">Blocaje? (opțional)</label>
<textarea value={blockers} onChange={(e) => setBlockers(e.target.value)}
placeholder="Ce te blochează sau necesită ajutor?"
rows={2} 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 resize-none" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-ink">Energie azi</label>
<div className="flex gap-3">
{MOODS.map((emoji, i) => (
<button key={i} onClick={() => setMood(i + 1)}
className={`text-2xl rounded-lg p-2 transition-all ${mood === i + 1 ? 'bg-primary/10 scale-110 ring-2 ring-primary/40' : 'hover:bg-muted/50'}`}>
{emoji}
</button>
))}
</div>
</div>
</div>
<button onClick={() => submitMut.mutate()} disabled={(!yesterday.trim() && !today.trim()) || submitMut.isPending}
className="w-full rounded-lg bg-primary py-3 text-sm font-semibold text-white hover:bg-primary/90 disabled:opacity-50">
{submitMut.isPending ? 'Se salvează…' : 'Salvează standup-ul'}
</button>
</>
)}
</div>
);
}