feat(CC-089): add Habit Tracker page (30-day grid, streak, log, preset habits)
This commit is contained in:
parent
7a30bbf1ac
commit
6dd3c0224f
1 changed files with 191 additions and 0 deletions
191
src/app/dashboard/habits/page.tsx
Normal file
191
src/app/dashboard/habits/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue