From f1b38ac4970270492e8f7d8b1b55e2bb480994ba Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Wed, 29 Jul 2026 19:26:06 +0000 Subject: [PATCH] 21: habit analytics - recharts line/bar chart for 30-day completions and top streaks --- apps/web/app/(dashboard)/habits/page.tsx | 6 + .../web/components/habits/habit-analytics.tsx | 142 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 apps/web/components/habits/habit-analytics.tsx diff --git a/apps/web/app/(dashboard)/habits/page.tsx b/apps/web/app/(dashboard)/habits/page.tsx index 198ee8b..3b4514b 100644 --- a/apps/web/app/(dashboard)/habits/page.tsx +++ b/apps/web/app/(dashboard)/habits/page.tsx @@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { HabitCreateDialog } from "@/components/habits/habit-create-dialog"; import { HabitCompletionDialog } from "@/components/habits/habit-completion-dialog"; import { HabitCalendarHeatmap } from "@/components/habits/habit-calendar-heatmap"; +import { HabitAnalytics } from "@/components/habits/habit-analytics"; import { toast } from "sonner"; interface Habit { @@ -214,6 +215,11 @@ export default function HabitsPage() { )} + {/* Analytics */} +
+ +
+ ([]); + const [completions, setCompletions] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) return; + setLoading(true); + + Promise.all([ + fetch('/api/habits/streaks').then((r) => r.json()), + ...habits.map((h) => + fetch( + '/api/domains/' + domainId + '/habits/' + h.id + '/completions?from=' + daysAgo(30) + '&order=asc&limit=365' + ).then((r) => r.json()) + ), + ]) + .then(([streaksData, ...completionsData]) => { + setStreaks((streaksData.streaks || []).slice(0, 5)); + + const dateMap = new Map(); + for (const data of completionsData) { + for (const item of data.items || []) { + const d = item.date?.split('T')[0]; + if (d) dateMap.set(d, (dateMap.get(d) || 0) + 1); + } + } + const sorted = Array.from(dateMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, count]) => ({ date, count })); + setCompletions(sorted); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [open, domainId, habits]); + + if (!open) { + return ( + + ); + } + + return ( +
+
+

Analytics (30 days)

+ +
+ + {loading ? ( +

Loading analytics...

+ ) : ( +
+ + + + + Daily Completions + + + + {completions.length === 0 ? ( +

No data yet

+ ) : ( + + + + v.slice(5)} /> + + + + + + )} +
+
+ + + + + + Top Streaks + + + + {streaks.length === 0 ? ( +

No streaks yet

+ ) : ( + + + + + + + + + + )} +
+
+
+ )} +
+ ); +} + +function daysAgo(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().split('T')[0]; +}