T1/Phase 1: scaffold Vite SPA + Hono API + Bun worker
- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs - apps/worker: Bun worker stub, DB connection, graceful SIGTERM - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference) - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy) - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api - docker-compose.yml: 4-service target (api, spa, db, worker) - packages/db/src/client.ts: shared Drizzle client for api + worker - db/client.ts: root-level alias for convenience Parent: t_e1cbd87d -> t_24c9c3fd (T0)
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, LineChart, Line, CartesianGrid } from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BarChart3, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface HabitAnalyticsProps {
|
||||
domainId: string;
|
||||
habits: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
interface StreakItem {
|
||||
habitId: string;
|
||||
habitName: string;
|
||||
currentStreak: number;
|
||||
bestStreak: number;
|
||||
}
|
||||
|
||||
interface CompletionDay {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function HabitAnalytics({ domainId, habits }: HabitAnalyticsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [streaks, setStreaks] = useState<StreakItem[]>([]);
|
||||
const [completions, setCompletions] = useState<CompletionDay[]>([]);
|
||||
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<string, number>();
|
||||
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 (
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<BarChart3 className="mr-2 h-4 w-4" />
|
||||
Show analytics
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">Analytics (30 days)</h3>
|
||||
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading analytics...</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
Daily Completions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{completions.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">No data yet</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<LineChart data={completions}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10 }} tickFormatter={(v) => v.slice(5)} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="count" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<BarChart3 className="h-4 w-4 text-primary" />
|
||||
Top Streaks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{streaks.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">No streaks yet</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={streaks} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis type="number" tick={{ fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="habitName" width={80} tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="bestStreak" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function daysAgo(n: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - n);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Completion {
|
||||
id: string;
|
||||
date: string;
|
||||
value: number;
|
||||
mood: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface HabitCalendarHeatmapProps {
|
||||
habitId: string;
|
||||
domainId: string;
|
||||
}
|
||||
|
||||
export function HabitCalendarHeatmap({ habitId, domainId }: HabitCalendarHeatmapProps) {
|
||||
const [completions, setCompletions] = useState<Completion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCompletions = async () => {
|
||||
try {
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 365);
|
||||
|
||||
const res = await fetch(
|
||||
`/api/domains/${domainId}/habits/${habitId}/completions?from=${from.toISOString()}&to=${to.toISOString()}&limit=400`
|
||||
);
|
||||
const data = await res.json();
|
||||
setCompletions(data.items || []);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCompletions();
|
||||
}, [habitId, domainId]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="py-4 text-center text-sm text-muted-foreground">Loading heatmap...</div>;
|
||||
}
|
||||
|
||||
// Build a map of date -> completion
|
||||
const completionMap = new Map<string, Completion>();
|
||||
for (const c of completions) {
|
||||
const dateKey = new Date(c.date).toISOString().split('T')[0];
|
||||
completionMap.set(dateKey, c);
|
||||
}
|
||||
|
||||
// Generate last 365 days
|
||||
const today = new Date();
|
||||
const days: { date: Date; dateStr: string; completion?: Completion }[] = [];
|
||||
for (let i = 364; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
days.push({ date: d, dateStr, completion: completionMap.get(dateStr) });
|
||||
}
|
||||
|
||||
// Group by weeks (columns)
|
||||
const weeks: typeof days[] = [];
|
||||
let currentWeek: typeof days = [];
|
||||
for (const day of days) {
|
||||
currentWeek.push(day);
|
||||
if (day.date.getDay() === 6) {
|
||||
weeks.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
}
|
||||
if (currentWeek.length > 0) weeks.push(currentWeek);
|
||||
|
||||
const getIntensity = (completion?: Completion): string => {
|
||||
if (!completion) return 'bg-muted';
|
||||
const v = completion.value || 1;
|
||||
if (v >= 4) return 'bg-green-600';
|
||||
if (v >= 3) return 'bg-green-500';
|
||||
if (v >= 2) return 'bg-green-400';
|
||||
return 'bg-green-300';
|
||||
};
|
||||
|
||||
const getTooltip = (day: typeof days[0]): string => {
|
||||
if (!day.completion) {
|
||||
return day.date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) + ' — No entry';
|
||||
}
|
||||
const parts = [
|
||||
day.date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }),
|
||||
`Value: ${day.completion.value}`,
|
||||
];
|
||||
if (day.completion.mood) parts.push(`Mood: ${day.completion.mood}/5`);
|
||||
if (day.completion.notes) parts.push(`Notes: ${day.completion.notes}`);
|
||||
return parts.join(' | ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex gap-1">
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} className="flex flex-col gap-1">
|
||||
{week.map((day) => (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
className={`h-3 w-3 rounded-sm ${getIntensity(day.completion)}`}
|
||||
title={getTooltip(day)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Less</span>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="h-3 w-3 rounded-sm bg-muted" />
|
||||
<div className="h-3 w-3 rounded-sm bg-green-300" />
|
||||
<div className="h-3 w-3 rounded-sm bg-green-400" />
|
||||
<div className="h-3 w-3 rounded-sm bg-green-500" />
|
||||
<div className="h-3 w-3 rounded-sm bg-green-600" />
|
||||
</div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { MoreHorizontal, Pencil, Trash2, Flame } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Habit {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
domain: string;
|
||||
frequency?: string;
|
||||
difficulty?: string;
|
||||
streak?: number;
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function HabitCard() {
|
||||
const [habits, setHabits] = useState<Habit[]>([]);
|
||||
const [domainMap, setDomainMap] = useState<Map<string, string>>(new Map());
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [editing, setEditing] = useState<Habit | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editDomain, setEditDomain] = useState("");
|
||||
const [editFrequency, setEditFrequency] = useState("daily");
|
||||
const [editDifficulty, setEditDifficulty] = useState("medium");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => { fetchHabits(); fetchDomains(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
setEditName(editing.name);
|
||||
setEditDescription(editing.description || "");
|
||||
setEditDomain(editing.domain);
|
||||
setEditFrequency(editing.frequency || "daily");
|
||||
setEditDifficulty(editing.difficulty || "medium");
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
async function fetchHabits() {
|
||||
try {
|
||||
const res = await fetch("/api/habits?sort=-created");
|
||||
const data = await res.json();
|
||||
setHabits(data.items || []);
|
||||
} catch { toast.error("Unable to load habits"); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function fetchDomains() {
|
||||
try {
|
||||
const res = await fetch("/api/domains?sort=sort_order");
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
const map = new Map<string, string>();
|
||||
for (const d of items) map.set(d.id, d.name);
|
||||
setDomainMap(map);
|
||||
setDomains(items);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch("/api/habits/" + id, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success("Habit deleted");
|
||||
setHabits((h) => h.filter((x) => x.id !== id));
|
||||
} catch { toast.error("Unable to delete habit"); }
|
||||
finally { setDeleting(false); setDeleteId(null); }
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editing || !editName.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/habits/" + editing.id, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: editName.trim(),
|
||||
description: editDescription || undefined,
|
||||
domain: editDomain,
|
||||
frequency: editFrequency,
|
||||
difficulty: editDifficulty,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success("Habit updated");
|
||||
setEditing(null);
|
||||
await fetchHabits();
|
||||
} catch {
|
||||
toast.error("Unable to update habit");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-muted-foreground">Loading habits...</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{habits.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground">No habits yet. Create your first habit to get started!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{habits.map((habit) => (
|
||||
<Card key={habit.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{habit.name}</span>
|
||||
{habit.streak && habit.streak > 0 && (
|
||||
<span className="flex items-center gap-1 text-sm text-orange-500">
|
||||
<Flame className="h-4 w-4" /> {habit.streak}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs">{domainMap.get(habit.domain) || habit.domain}</Badge>
|
||||
{habit.frequency && <Badge variant="secondary" className="text-xs">{habit.frequency}</Badge>}
|
||||
{habit.difficulty && <Badge variant="secondary" className="text-xs">{habit.difficulty}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditing(habit)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => setDeleteId(habit.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Habit Dialog */}
|
||||
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit habit</DialogTitle>
|
||||
<DialogDescription>Update your habit details.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-name">Name</Label>
|
||||
<Input id="edit-habit-name" value={editName} onChange={(e) => setEditName(e.target.value)} autoFocus required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-description">Description</Label>
|
||||
<Textarea id="edit-habit-description" value={editDescription} onChange={(e) => setEditDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-domain">Domain</Label>
|
||||
{domains.length > 0 ? (
|
||||
<Select value={editDomain} onValueChange={setEditDomain}>
|
||||
<SelectTrigger id="edit-habit-domain">
|
||||
<SelectValue placeholder="Select a domain" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{domains.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
<span className="mr-2 inline-block h-3 w-3 rounded-full" style={{ backgroundColor: d.color }} />
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No domains found.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-frequency">Frequency</Label>
|
||||
<Select value={editFrequency} onValueChange={setEditFrequency}>
|
||||
<SelectTrigger id="edit-habit-frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-difficulty">Difficulty</Label>
|
||||
<Select value={editDifficulty} onValueChange={setEditDifficulty}>
|
||||
<SelectTrigger id="edit-habit-difficulty">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="hard">Hard</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditing(null)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !editName.trim()}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete habit?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteId && handleDelete(deleteId)} disabled={deleting}>{deleting ? "Deleting..." : "Delete"}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface Habit {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string | null;
|
||||
moodTracking: boolean;
|
||||
}
|
||||
|
||||
interface HabitCompletionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
habit: Habit;
|
||||
onComplete: (value: number, mood?: number, notes?: string) => void;
|
||||
}
|
||||
|
||||
const moodEmojis = [
|
||||
{ value: 1, emoji: '😞', label: 'Bad' },
|
||||
{ value: 2, emoji: '😐', label: 'Okay' },
|
||||
{ value: 3, emoji: '🙂', label: 'Good' },
|
||||
{ value: 4, emoji: '😊', label: 'Great' },
|
||||
{ value: 5, emoji: '🤩', label: 'Amazing' },
|
||||
];
|
||||
|
||||
export function HabitCompletionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
habit,
|
||||
onComplete,
|
||||
}: HabitCompletionDialogProps) {
|
||||
const [value, setValue] = useState('1');
|
||||
const [mood, setMood] = useState<number | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Log "{habit.name}"</DialogTitle>
|
||||
<DialogDescription>Record your progress for today.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{habit.unit && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="completion-value">Value ({habit.unit})</Label>
|
||||
<Input
|
||||
id="completion-value"
|
||||
type="number"
|
||||
min={1}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{habit.moodTracking && (
|
||||
<div className="space-y-2">
|
||||
<Label>Mood</Label>
|
||||
<div className="flex gap-2">
|
||||
{moodEmojis.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
onClick={() => setMood(mood === m.value ? null : m.value)}
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg text-lg transition-colors ${
|
||||
mood === m.value
|
||||
? 'bg-primary text-primary-foreground ring-2 ring-primary'
|
||||
: 'bg-muted hover:bg-accent'
|
||||
}`}
|
||||
title={m.label}
|
||||
aria-label={`Mood: ${m.label}`}
|
||||
>
|
||||
{m.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="completion-notes">Notes (optional)</Label>
|
||||
<Textarea
|
||||
id="completion-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="How did it go?"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={() => onComplete(parseInt(value) || 1, mood || undefined, notes || undefined)}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface HabitCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
domainId: string;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function HabitCreateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
domainId,
|
||||
onCreated,
|
||||
}: HabitCreateDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [frequency, setFrequency] = useState<'daily' | 'weekly' | 'custom'>('daily');
|
||||
const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard'>('medium');
|
||||
const [goalPerPeriod, setGoalPerPeriod] = useState('1');
|
||||
const [unit, setUnit] = useState('');
|
||||
const [reminderTime, setReminderTime] = useState('');
|
||||
const [moodTracking, setMoodTracking] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setFrequency('daily');
|
||||
setDifficulty('medium');
|
||||
setGoalPerPeriod('1');
|
||||
setUnit('');
|
||||
setReminderTime('');
|
||||
setMoodTracking(false);
|
||||
setError('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!domainId) {
|
||||
setError('No domain selected');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
name,
|
||||
frequency,
|
||||
difficulty,
|
||||
goalPerPeriod: parseInt(goalPerPeriod, 10) || 1,
|
||||
moodTracking,
|
||||
};
|
||||
if (description) body.description = description;
|
||||
if (unit) body.unit = unit;
|
||||
if (reminderTime) body.reminderTime = reminderTime;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/domains/${domainId}/habits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error?.message || 'Unable to create habit');
|
||||
}
|
||||
|
||||
toast.success('Habit created');
|
||||
onOpenChange(false);
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to create habit');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Habit</DialogTitle>
|
||||
<DialogDescription>Create a new habit to track daily or weekly.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="habit-name">Name *</Label>
|
||||
<Input
|
||||
id="habit-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Morning meditation"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="habit-description">Description</Label>
|
||||
<Textarea
|
||||
id="habit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional details..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="habit-frequency">Frequency</Label>
|
||||
<Select value={frequency} onValueChange={(v) => setFrequency(v as any)}>
|
||||
<SelectTrigger id="habit-frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="habit-difficulty">Difficulty</Label>
|
||||
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as any)}>
|
||||
<SelectTrigger id="habit-difficulty">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="hard">Hard</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="habit-goal">Goal per period</Label>
|
||||
<Input
|
||||
id="habit-goal"
|
||||
type="number"
|
||||
min={1}
|
||||
value={goalPerPeriod}
|
||||
onChange={(e) => setGoalPerPeriod(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="habit-unit">Unit (optional)</Label>
|
||||
<Input
|
||||
id="habit-unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
placeholder="e.g. minutes, pages"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="habit-reminder">Reminder time (optional)</Label>
|
||||
<Input
|
||||
id="habit-reminder"
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="habit-mood"
|
||||
checked={moodTracking}
|
||||
onCheckedChange={setMoodTracking}
|
||||
/>
|
||||
<Label htmlFor="habit-mood">Enable mood tracking</Label>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive" role="alert">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting || !name || !domainId}>
|
||||
{submitting ? 'Creating...' : 'Create Habit'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Habit {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
domainId: string;
|
||||
frequency: 'daily' | 'weekly' | 'custom';
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
goalPerPeriod: number;
|
||||
unit: string | null;
|
||||
active: boolean;
|
||||
moodTracking: boolean;
|
||||
tags: { id: string; name: string; color: string | null }[];
|
||||
}
|
||||
|
||||
interface HabitEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
habit: Habit;
|
||||
domainId: string;
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function HabitEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
habit,
|
||||
domainId,
|
||||
onUpdated,
|
||||
}: HabitEditDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [frequency, setFrequency] = useState<'daily' | 'weekly' | 'custom'>('daily');
|
||||
const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard'>('medium');
|
||||
const [goalPerPeriod, setGoalPerPeriod] = useState('1');
|
||||
const [unit, setUnit] = useState('');
|
||||
const [reminderTime, setReminderTime] = useState('');
|
||||
const [moodTracking, setMoodTracking] = useState(false);
|
||||
const [active, setActive] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const [availableTags, setAvailableTags] = useState<{ id: string; name: string; color: string | null }[]>([]);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && habit) {
|
||||
setName(habit.name);
|
||||
setDescription(habit.description || '');
|
||||
setFrequency(habit.frequency);
|
||||
setDifficulty(habit.difficulty);
|
||||
setGoalPerPeriod(String(habit.goalPerPeriod));
|
||||
setUnit(habit.unit || '');
|
||||
setReminderTime('');
|
||||
setMoodTracking(habit.moodTracking);
|
||||
setActive(habit.active);
|
||||
setSelectedTagIds(habit.tags.map(t => t.id));
|
||||
setError('');
|
||||
|
||||
fetch(`/api/domains/${domainId}/tags`)
|
||||
.then(res => res.json())
|
||||
.then(data => setAvailableTags(data.items || []))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [open, habit, domainId]);
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
setSelectedTagIds(prev =>
|
||||
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!domainId) {
|
||||
setError('No domain selected');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
name,
|
||||
frequency,
|
||||
difficulty,
|
||||
goalPerPeriod: parseInt(goalPerPeriod, 10) || 1,
|
||||
moodTracking,
|
||||
active,
|
||||
};
|
||||
if (description) body.description = description;
|
||||
if (unit) body.unit = unit;
|
||||
if (reminderTime) body.reminderTime = reminderTime;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/domains/${domainId}/habits/${habit.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error?.message || 'Unable to update habit');
|
||||
}
|
||||
|
||||
// Sync tags
|
||||
const currentTagIds = habit.tags.map(t => t.id);
|
||||
const toRemove = currentTagIds.filter(id => !selectedTagIds.includes(id));
|
||||
const toAdd = selectedTagIds.filter(id => !currentTagIds.includes(id));
|
||||
|
||||
await Promise.all([
|
||||
...toRemove.map(tagId =>
|
||||
fetch(`/api/domains/${domainId}/habits/${habit.id}/tags`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tagId }),
|
||||
})
|
||||
),
|
||||
...toAdd.map(tagId =>
|
||||
fetch(`/api/domains/${domainId}/habits/${habit.id}/tags`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tagId }),
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
toast.success('Habit updated');
|
||||
onOpenChange(false);
|
||||
onUpdated();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to update habit');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const response = await fetch(`/api/domains/${domainId}/habits/${habit.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error?.message || 'Unable to delete habit');
|
||||
}
|
||||
|
||||
toast.success('Habit deleted');
|
||||
setDeleteOpen(false);
|
||||
onOpenChange(false);
|
||||
onUpdated();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Unable to delete habit');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Habit</DialogTitle>
|
||||
<DialogDescription>Update your habit details.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-name">Name *</Label>
|
||||
<Input
|
||||
id="edit-habit-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Morning meditation"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-description">Description</Label>
|
||||
<Textarea
|
||||
id="edit-habit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional details..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-frequency">Frequency</Label>
|
||||
<Select value={frequency} onValueChange={(v) => setFrequency(v as any)}>
|
||||
<SelectTrigger id="edit-habit-frequency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-difficulty">Difficulty</Label>
|
||||
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as any)}>
|
||||
<SelectTrigger id="edit-habit-difficulty">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="hard">Hard</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-goal">Goal per period</Label>
|
||||
<Input
|
||||
id="edit-habit-goal"
|
||||
type="number"
|
||||
min={1}
|
||||
value={goalPerPeriod}
|
||||
onChange={(e) => setGoalPerPeriod(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-unit">Unit (optional)</Label>
|
||||
<Input
|
||||
id="edit-habit-unit"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
placeholder="e.g. minutes, pages"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-habit-reminder">Reminder time (optional)</Label>
|
||||
<Input
|
||||
id="edit-habit-reminder"
|
||||
type="time"
|
||||
value={reminderTime}
|
||||
onChange={(e) => setReminderTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="edit-habit-active"
|
||||
checked={active}
|
||||
onCheckedChange={setActive}
|
||||
/>
|
||||
<Label htmlFor="edit-habit-active">Active</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="edit-habit-mood"
|
||||
checked={moodTracking}
|
||||
onCheckedChange={setMoodTracking}
|
||||
/>
|
||||
<Label htmlFor="edit-habit-mood">Enable mood tracking</Label>
|
||||
</div>
|
||||
|
||||
{availableTags.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableTags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedTagIds.includes(tag.id)
|
||||
? 'ring-2 ring-primary ring-offset-1'
|
||||
: 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
style={{ backgroundColor: tag.color || '#e2e8f0', color: '#1e293b' }}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive" role="alert">{error}</p>}
|
||||
|
||||
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting || !name || !domainId}>
|
||||
{submitting ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Habit</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{habit.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import CalendarHeatmap from 'react-calendar-heatmap';
|
||||
import type { Habit } from '@project-e/shared';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface HeatmapValue {
|
||||
date: Date | string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface HabitHeatmapProps {
|
||||
habits: Habit[];
|
||||
}
|
||||
|
||||
export function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
||||
const [values, setValues] = useState<HeatmapValue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHeatmapData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [habits.length]);
|
||||
|
||||
async function fetchHeatmapData() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/habit-logs?start=${oneYearAgo.toISOString()}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Habit logs could not be loaded.');
|
||||
}
|
||||
const data = await response.json();
|
||||
const logs: Array<{ logged_at: string }> = data.items || [];
|
||||
|
||||
// Group logs by calendar date.
|
||||
const byDate: Record<string, number> = {};
|
||||
logs.forEach((log) => {
|
||||
const date = new Date(log.logged_at).toISOString().split('T')[0];
|
||||
byDate[date] = (byDate[date] || 0) + 1;
|
||||
});
|
||||
|
||||
const heatmapValues: HeatmapValue[] = Object.entries(byDate).map(
|
||||
([date, count]) => ({
|
||||
date,
|
||||
count,
|
||||
})
|
||||
);
|
||||
|
||||
setValues(heatmapValues);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch heatmap data:', error);
|
||||
setError('Habit activity could not be loaded.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-3 text-sm text-muted-foreground" role="alert">
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" size="sm" onClick={fetchHeatmapData}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setFullYear(today.getFullYear() - 1);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<p className="sr-only">
|
||||
Habit completion heatmap for the past year. {values.length === 0
|
||||
? 'No habit completions recorded.'
|
||||
: values.map((value) => `${new Date(value.date).toLocaleDateString()}: ${value.count} completion${value.count === 1 ? '' : 's'}.`).join(' ')}
|
||||
</p>
|
||||
<CalendarHeatmap
|
||||
startDate={oneYearAgo}
|
||||
endDate={today}
|
||||
values={values}
|
||||
classForValue={(value) => {
|
||||
if (!value || value.count === 0) return 'heatmap-empty';
|
||||
if (value.count <= 1) return 'heatmap-scale-1';
|
||||
if (value.count <= 2) return 'heatmap-scale-2';
|
||||
if (value.count <= 3) return 'heatmap-scale-3';
|
||||
return 'heatmap-scale-4';
|
||||
}}
|
||||
tooltipDataAttrs={(value) => {
|
||||
if (!value || value.count === 0) return null;
|
||||
const date = new Date(value.date).toLocaleDateString();
|
||||
return {
|
||||
'data-tip': `${date}: ${value.count} habit${value.count === 1 ? '' : 's'}`,
|
||||
};
|
||||
}}
|
||||
showWeekdayLabels
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user