From 6dd3c0224fad7524b697759e39d08ae026d28b7b Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 17:51:18 +0000 Subject: [PATCH] feat(CC-089): add Habit Tracker page (30-day grid, streak, log, preset habits) --- src/app/dashboard/habits/page.tsx | 191 ++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 src/app/dashboard/habits/page.tsx diff --git a/src/app/dashboard/habits/page.tsx b/src/app/dashboard/habits/page.tsx new file mode 100644 index 0000000..c7dd186 --- /dev/null +++ b/src/app/dashboard/habits/page.tsx @@ -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>({}); + const [newHabit, setNewHabit] = useState({ name: '', metric: '', unit: '', icon: '⭐' }); + + const { data: observations = [], isLoading } = useQuery({ + queryKey: ['habits', tenantId], + queryFn: () => apiFetch('/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(); + 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> = {}; + 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 ( +
+
+
+

Habit Tracker

+

{metrics.length} obiceiuri urmărite · ultimele 30 zile

+
+ +
+ + {showAdd && ( +
+

Obicei nou

+
+ 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" /> + 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" /> + 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" /> +
+
+ + +
+

Sau adaugă din presetări:

+
+ {PRESET_HABITS.filter((h) => !metrics.some((m) => m.metric === h.metric)).map((h) => ( + + ))} +
+
+ )} + + {isLoading ? ( +
Se încarcă…
+ ) : metrics.length === 0 ? ( +
+

🎯

+

Niciun obicei urmărit. Adaugă primul sau alege din presetări.

+
+ ) : ( +
+ {metrics.map((h) => { + const todayDone = Boolean(obsMap[h.metric]?.[TODAY]); + const str = streak(h.metric); + return ( +
+
+ {h.icon} +
+

{h.name}

+

Streak: {str} zile 🔥

+
+ {!todayDone ? ( +
+ 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" /> + +
+ ) : ( + ✓ {obsMap[h.metric][TODAY]}{h.unit} + )} +
+ {/* 30-day grid */} +
+ {LAST_30.map((day) => { + const val = obsMap[h.metric]?.[day]; + return ( +
+ ); + })} +
+
+ 30 zile în urmăAzi +
+
+ ); + })} +
+ )} +
+ ); +}