feat(CC-089): add Habit Tracker page (30-day grid, streak, log, preset habits)

This commit is contained in:
admin-valentin 2026-08-02 17:51:18 +00:00
parent 7a30bbf1ac
commit 6dd3c0224f

View file

@ -0,0 +1,191 @@
'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 Observation { id: string; metric: string; value: string; subjectType: string; createdAt: string; observedAt: string | null; }
const TODAY = new Date().toISOString().slice(0, 10);
const LAST_30 = Array.from({ length: 30 }, (_, i) => {
const d = new Date(); d.setDate(d.getDate() - (29 - i)); return d.toISOString().slice(0, 10);
});
const PRESET_HABITS = [
{ name: 'Exerciții fizice', metric: 'exercitiu', unit: 'min', icon: '🏃' },
{ name: 'Lectură', metric: 'lectura', unit: 'pag', icon: '📚' },
{ name: 'Meditație', metric: 'meditatie', unit: 'min', icon: '🧘' },
{ name: 'Apă (pahare)', metric: 'apa', unit: 'pahare', icon: '💧' },
{ name: 'Somn', metric: 'somn', unit: 'ore', icon: '😴' },
{ name: 'Jurnal', metric: 'jurnal', unit: 'min', icon: '✍️' },
];
export default function HabitsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [showAdd, setShowAdd] = useState(false);
const [logForm, setLogForm] = useState<Record<string, string>>({});
const [newHabit, setNewHabit] = useState({ name: '', metric: '', unit: '', icon: '⭐' });
const { data: observations = [], isLoading } = useQuery({
queryKey: ['habits', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 30_000,
});
const habitObs = useMemo(() =>
observations.filter((o) => o.subjectType === 'habit' ||
PRESET_HABITS.some((h) => o.metric === h.metric)),
[observations]);
const metrics = useMemo(() => {
const found = new Set<string>();
for (const o of habitObs) found.add(o.metric);
const presets = PRESET_HABITS.filter((h) => found.has(h.metric));
const custom = [...found].filter((m) => !PRESET_HABITS.some((h) => h.metric === m))
.map((m) => ({ name: m, metric: m, unit: '', icon: '⭐' }));
return [...presets, ...custom];
}, [habitObs]);
const obsMap = useMemo(() => {
const map: Record<string, Record<string, string>> = {};
for (const o of habitObs) {
const d = (o.observedAt ?? o.createdAt).slice(0, 10);
if (!map[o.metric]) map[o.metric] = {};
map[o.metric][d] = o.value;
}
return map;
}, [habitObs]);
function streak(metric: string): number {
let s = 0;
for (let i = LAST_30.length - 1; i >= 0; i--) {
if (obsMap[metric]?.[LAST_30[i]]) s++; else break;
}
return s;
}
const logMut = useMutation({
mutationFn: ({ metric, value, unit }: { metric: string; value: string; unit: string }) =>
apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
metric, value, unit: unit || undefined,
subjectType: 'habit', confidence: 1,
observedAt: new Date().toISOString(),
}}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['habits', tenantId] }); setLogForm({}); },
});
const addHabitMut = useMutation({
mutationFn: () => apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
metric: newHabit.metric || newHabit.name.toLowerCase().replace(/\s+/g, '-'),
value: '0', unit: newHabit.unit || undefined,
subjectType: 'habit', confidence: 1,
observedAt: new Date(0).toISOString(),
}}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['habits', tenantId] }); setShowAdd(false); setNewHabit({ name: '', metric: '', unit: '', icon: '⭐' }); },
});
return (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Habit Tracker</h1>
<p className="text-sm text-ink-faint mt-1">{metrics.length} obiceiuri urmărite · ultimele 30 zile</p>
</div>
<button onClick={() => setShowAdd(true)}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
+ Obicei nou
</button>
</div>
{showAdd && (
<div className="card p-5 space-y-3">
<p className="text-sm font-semibold text-ink">Obicei nou</p>
<div className="grid gap-3 sm:grid-cols-3">
<input placeholder="Nume (ex: Alergare)" value={newHabit.name}
onChange={(e) => setNewHabit((p) => ({ ...p, name: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<input placeholder="Unitate (ex: km, min)" value={newHabit.unit}
onChange={(e) => setNewHabit((p) => ({ ...p, unit: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<input placeholder="Icon emoji" value={newHabit.icon}
onChange={(e) => setNewHabit((p) => ({ ...p, icon: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div className="flex gap-2">
<button onClick={() => addHabitMut.mutate()} disabled={!newHabit.name || addHabitMut.isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
{addHabitMut.isPending ? 'Se salvează…' : 'Adaugă'}
</button>
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
</div>
<p className="text-[10px] text-ink-faint">Sau adaugă din presetări:</p>
<div className="flex flex-wrap gap-2">
{PRESET_HABITS.filter((h) => !metrics.some((m) => m.metric === h.metric)).map((h) => (
<button key={h.metric} onClick={() => { addHabitMut.mutate(); setNewHabit({ name: h.name, metric: h.metric, unit: h.unit, icon: h.icon }); }}
className="rounded-full border px-3 py-1 text-xs hover:border-primary/40">
{h.icon} {h.name}
</button>
))}
</div>
</div>
)}
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : metrics.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-3xl">🎯</p>
<p className="text-sm text-ink-faint">Niciun obicei urmărit. Adaugă primul sau alege din presetări.</p>
</div>
) : (
<div className="space-y-4">
{metrics.map((h) => {
const todayDone = Boolean(obsMap[h.metric]?.[TODAY]);
const str = streak(h.metric);
return (
<div key={h.metric} className="card p-4 space-y-3">
<div className="flex items-center gap-3">
<span className="text-2xl shrink-0">{h.icon}</span>
<div className="flex-1">
<p className="text-sm font-semibold text-ink">{h.name}</p>
<p className="text-[10px] text-ink-faint">Streak: {str} zile 🔥</p>
</div>
{!todayDone ? (
<div className="flex items-center gap-2 shrink-0">
<input placeholder={`Valoare${h.unit ? ` (${h.unit})` : ''}`}
value={logForm[h.metric] ?? ''}
onChange={(e) => setLogForm((p) => ({ ...p, [h.metric]: e.target.value }))}
className="w-28 rounded-lg border bg-background px-2 py-1 text-xs text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<button onClick={() => logMut.mutate({ metric: h.metric, value: logForm[h.metric] || '1', unit: h.unit })}
className="rounded-lg bg-primary px-3 py-1 text-xs font-medium text-white hover:bg-primary/90">
Log
</button>
</div>
) : (
<span className="text-signal-ok font-bold shrink-0"> {obsMap[h.metric][TODAY]}{h.unit}</span>
)}
</div>
{/* 30-day grid */}
<div className="flex gap-0.5">
{LAST_30.map((day) => {
const val = obsMap[h.metric]?.[day];
return (
<div key={day} title={`${day}: ${val ?? '—'}`}
className={`h-5 flex-1 rounded-sm ${val ? 'bg-primary/70' : 'bg-muted'}`} />
);
})}
</div>
<div className="flex justify-between text-[9px] text-ink-faint">
<span>30 zile în urmă</span><span>Azi</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
}