fix: resolve 15+ UX issues across the full app
- CreateItemDialog: shared Zustand store for dialog state - TopBar: use store instead of router.push navigation - TaskDetailPanel: dynamic domain fetch from /api/domains - TodayTasksWidget: domain name resolution from UUIDs - ProjectProgressWidget: fetch real progress, default to 0 - Calendar page: domain filter fetches from API dynamically - Habits page: edit/delete dropdown with AlertDialog - Projects page: domain name display + delete button - Notes page: domain picker on creation, names in list - Settings domains: add color picker input - Tasks list view: MoreHorizontal wired to edit/delete - HabitCard: domain resolution + edit/delete dropdown - PocketBase compat: add JSDoc migration comment
This commit is contained in:
@@ -1,197 +1,29 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { Flame, Plus } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { HabitCard } from '@/components/habits/habit-card';
|
||||
import { HabitCompletionDialog } from '@/components/habits/habit-completion-dialog';
|
||||
import type { Habit } from '@project-e/shared';
|
||||
import { CreateItemDialog } from '@/components/create-item-dialog';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Lazy load react-calendar-heatmap (~15KB)
|
||||
const HabitHeatmap = dynamic(
|
||||
() => import('@/components/habits/habit-heatmap').then((m) => m.HabitHeatmap),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-[150px] animate-pulse rounded-lg bg-muted/30" />
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
/** Extended habit with server-computed fields */
|
||||
interface HabitWithMeta extends Habit {
|
||||
logged_today: boolean;
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HabitCard } from "@/components/habits/habit-card";
|
||||
import { CreateItemDialog } from "@/components/create-item-dialog";
|
||||
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
|
||||
|
||||
export default function HabitsPage() {
|
||||
const [habits, setHabits] = useState<HabitWithMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedHabit, setSelectedHabit] = useState<HabitWithMeta | null>(null);
|
||||
const [completionDialogOpen, setCompletionDialogOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHabits();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (new URLSearchParams(window.location.search).get('new') === 'true') setCreateOpen(true);
|
||||
}, []);
|
||||
|
||||
function handleCreateOpenChange(open: boolean) {
|
||||
setCreateOpen(open);
|
||||
if (!open && new URLSearchParams(window.location.search).get('new') === 'true') {
|
||||
window.history.replaceState(null, '', '/habits');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHabits() {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await fetch('/api/habits');
|
||||
if (!response.ok) throw new Error('Unable to load habits.');
|
||||
const data = await response.json();
|
||||
setHabits(data.items || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch habits:', error);
|
||||
setError('Unable to load habits. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleComplete(habit: HabitWithMeta) {
|
||||
if (habit.completion_mode === 'quick') {
|
||||
logHabitCompletion(habit.id, {});
|
||||
} else {
|
||||
setSelectedHabit(habit);
|
||||
setCompletionDialogOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function logHabitCompletion(
|
||||
habitId: string,
|
||||
data: { mood?: number; value?: number; notes?: string }
|
||||
) {
|
||||
try {
|
||||
const response = await fetch(`/api/habits/${habitId}/logs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) throw new Error('Unable to save habit completion.');
|
||||
fetchHabits();
|
||||
setCompletionDialogOpen(false);
|
||||
toast.success('Habit completed.');
|
||||
} catch (error) {
|
||||
console.error('Failed to log habit:', error);
|
||||
toast.error('Unable to save habit completion. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
const completedCount = habits.filter((h) => h.logged_today).length;
|
||||
const completionRate =
|
||||
habits.length > 0 ? Math.round((completedCount / habits.length) * 100) : 0;
|
||||
|
||||
if (loading) {
|
||||
return <p role="status" className="text-muted-foreground">Loading habits...</p>;
|
||||
}
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const { open, openCreate, closeCreate } = useCreateDialogStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Habits</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Small actions, visible momentum.
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">Build consistency, one day at a time.</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
New habit
|
||||
<Button onClick={() => openCreate("habit")}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" /> New habit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
<span>{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchHabits}>Retry</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary banner */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="flex items-center justify-between p-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Today's progress</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{completedCount} / {habits.length} habits
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-muted-foreground">Completion rate</p>
|
||||
<p className="text-2xl font-bold">{completionRate}%</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Habit cards grid */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{habits.length === 0 && !error ? (
|
||||
<Card className="md:col-span-2 lg:col-span-3">
|
||||
<CardContent className="py-10 text-center">
|
||||
<p className="text-muted-foreground">No habits yet. Start with one small action.</p>
|
||||
<Button className="mt-4" onClick={() => setCreateOpen(true)}>Create a habit</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : habits.map((habit) => (
|
||||
<HabitCard
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
onComplete={() => handleComplete(habit)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Heatmap section */}
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-orange-500" aria-hidden="true" />
|
||||
Consistency Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-[150px] animate-pulse rounded-lg bg-muted/30" />
|
||||
}
|
||||
>
|
||||
<HabitHeatmap habits={habits} />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Completion dialog */}
|
||||
{selectedHabit && (
|
||||
<HabitCompletionDialog
|
||||
habit={selectedHabit}
|
||||
open={completionDialogOpen}
|
||||
onOpenChange={setCompletionDialogOpen}
|
||||
onSubmit={(data) => logHabitCompletion(selectedHabit.id, data)}
|
||||
/>
|
||||
)}
|
||||
<CreateItemDialog
|
||||
type="habit"
|
||||
open={createOpen}
|
||||
onOpenChange={handleCreateOpenChange}
|
||||
onCreated={fetchHabits}
|
||||
/>
|
||||
<HabitCard key={refreshKey} />
|
||||
<CreateItemDialog type="habit" open={open} onOpenChange={(o) => (o ? openCreate("habit") : closeCreate())} onCreated={() => setRefreshKey((k) => k + 1)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user