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:
Hermes
2026-08-01 01:15:31 +00:00
parent 9203aee758
commit fca56ab77e
312 changed files with 3489 additions and 196 deletions
@@ -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 &quot;{habit.name}&quot;</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 &quot;{habit.name}&quot;? 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>
);
}