feat: Phase 3 - Habits + Projects CRUD API, frontend, completions, sections
Habits REST API: - GET/POST /api/domains/[domainId]/habits (list with filters, create) - GET/PATCH/DELETE /api/domains/[domainId]/habits/[id] (detail, update, soft delete) - POST /api/domains/[domainId]/habits/[id]/complete (completion + streak calc) - GET /api/domains/[domainId]/habits/[id]/completions (list with date range) - POST/DELETE /api/domains/[domainId]/habits/[id]/tags Projects REST API: - GET/POST /api/domains/[domainId]/projects (list with task counts, create) - GET/PATCH/DELETE /api/domains/[domainId]/projects/[id] (detail with sections/tasks, update, soft delete) Sections REST API: - GET/POST /api/domains/[domainId]/projects/[projectId]/sections (list, create) - GET/PATCH/DELETE /api/domains/[domainId]/projects/[projectId]/sections/[id] Frontend: - Habits page: checklist view, difficulty badges, streak display, filter - Habit create dialog: name, description, frequency, difficulty, goal, unit, reminder, mood toggle - Habit completion dialog: value, mood (1-5 emoji), notes - Calendar heatmap: 365-day grid, color by value, hover tooltip - Projects page: grid of cards with progress bars, status badges, tags - Project detail page: sections board, drag tasks between sections - Project create dialog: name, description, status, color picker, target date - Section dialog: name, kind (section/milestone), status, target date Keyboard shortcuts: c h (new habit), c p (new project), c s (new section) All write routes follow AGENTS.md contract (Drizzle + recordActivity + pg_notify). Build, typecheck, and 15 new tests pass.
This commit is contained in:
@@ -1,29 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Plus, CheckCircle2, Circle, MoreHorizontal, Flame, Filter } 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";
|
||||
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 { 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;
|
||||
streakCount: number;
|
||||
bestStreak: number;
|
||||
active: boolean;
|
||||
moodTracking: boolean;
|
||||
tags: { id: string; name: string; color: string | null }[];
|
||||
}
|
||||
|
||||
const difficultyColors: Record<string, string> = {
|
||||
easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
};
|
||||
|
||||
export default function HabitsPage() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const { open, openCreate, closeCreate } = useCreateDialogStore();
|
||||
const [habits, setHabits] = useState<Habit[]>([]);
|
||||
const [domainId, setDomainId] = useState<string | null>(null);
|
||||
const [domains, setDomains] = useState<{ id: string; name: string }[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [completionHabit, setCompletionHabit] = useState<Habit | null>(null);
|
||||
const [expandedHabit, setExpandedHabit] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch domains
|
||||
useEffect(() => {
|
||||
fetch('/api/domains?sort=sort_order')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const items = data.items || [];
|
||||
setDomains(items);
|
||||
if (items.length > 0 && !domainId) {
|
||||
setDomainId(items[0].id);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch habits
|
||||
const fetchHabits = useCallback(async () => {
|
||||
if (!domainId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filter === 'active') params.set('active', 'true');
|
||||
const res = await fetch(`/api/domains/${domainId}/habits?${params}`);
|
||||
const data = await res.json();
|
||||
setHabits(data.items || []);
|
||||
} catch {
|
||||
toast.error('Failed to load habits');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [domainId, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHabits();
|
||||
}, [fetchHabits]);
|
||||
|
||||
// Complete a habit
|
||||
const handleComplete = async (habit: Habit, value?: number, mood?: number, notes?: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/domains/${domainId}/habits/${habit.id}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: value ?? 1, mood, notes }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to complete');
|
||||
toast.success(`"${habit.name}" logged!`);
|
||||
fetchHabits();
|
||||
} catch {
|
||||
toast.error('Failed to complete habit');
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for custom event to open create dialog
|
||||
useEffect(() => {
|
||||
const handler = () => setCreateOpen(true);
|
||||
document.addEventListener('open-create-habit', handler);
|
||||
return () => document.removeEventListener('open-create-habit', handler);
|
||||
}, []);
|
||||
|
||||
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">Build consistency, one day at a time.</p>
|
||||
<p className="mt-1 text-muted-foreground">Build streaks, track progress, stay consistent.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{domains.length > 1 && (
|
||||
<select
|
||||
value={domainId || ''}
|
||||
onChange={(e) => setDomainId(e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
aria-label="Select domain"
|
||||
>
|
||||
{domains.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="flex items-center gap-1 rounded-md border p-1">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${filter === 'all' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('active')}
|
||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${filter === 'active' ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Active
|
||||
</button>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
New habit
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => openCreate("habit")}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" /> New habit
|
||||
</Button>
|
||||
</div>
|
||||
<HabitCard key={refreshKey} />
|
||||
<CreateItemDialog type="habit" open={open} onOpenChange={(o) => (o ? openCreate("habit") : closeCreate())} onCreated={() => setRefreshKey((k) => k + 1)} />
|
||||
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-muted-foreground">Loading habits...</div>
|
||||
) : habits.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<Flame className="mx-auto h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
|
||||
<p className="mt-4 text-muted-foreground">No habits yet. Create your first one!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{habits.map((habit) => (
|
||||
<div key={habit.id} className="rounded-lg border bg-card">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleComplete(habit)}
|
||||
className="shrink-0 text-muted-foreground hover:text-primary transition-colors"
|
||||
aria-label={`Complete ${habit.name}`}
|
||||
>
|
||||
<Circle className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{habit.name}</span>
|
||||
<Badge variant="secondary" className={`text-xs ${difficultyColors[habit.difficulty] || ''}`}>
|
||||
{habit.difficulty}
|
||||
</Badge>
|
||||
{habit.unit && (
|
||||
<span className="text-xs text-muted-foreground">per {habit.unit}</span>
|
||||
)}
|
||||
</div>
|
||||
{habit.tags.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{habit.tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs"
|
||||
style={{ backgroundColor: tag.color || '#e2e8f0', color: '#1e293b' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center gap-1 text-sm" title="Current streak">
|
||||
<Flame className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||
<span className="font-semibold">{habit.streakCount}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCompletionHabit(habit)}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
aria-label={`Log ${habit.name} with details`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpandedHabit(expandedHabit === habit.id ? null : habit.id)}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
aria-label={expandedHabit === habit.id ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expandedHabit === habit.id && (
|
||||
<div className="border-t px-4 py-3">
|
||||
<HabitCalendarHeatmap habitId={habit.id} domainId={domainId!} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HabitCreateDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
domainId={domainId || ''}
|
||||
onCreated={fetchHabits}
|
||||
/>
|
||||
|
||||
{completionHabit && (
|
||||
<HabitCompletionDialog
|
||||
open={!!completionHabit}
|
||||
onOpenChange={(open) => { if (!open) setCompletionHabit(null); }}
|
||||
habit={completionHabit}
|
||||
onComplete={(value, mood, notes) => {
|
||||
handleComplete(completionHabit, value, mood, notes);
|
||||
setCompletionHabit(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,411 +1,302 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { ArrowLeft, Calendar, CheckCircle2, Circle, Flag } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Plus, ArrowLeft, GripVertical, MoreHorizontal } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { SectionDialog } from "@/components/projects/section-dialog";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Project {
|
||||
interface Section {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'active' | 'paused' | 'archived';
|
||||
domain: string;
|
||||
progress: number;
|
||||
task_count: number;
|
||||
completed_count: number;
|
||||
due_date?: string;
|
||||
projectId: string;
|
||||
kind: 'section' | 'milestone';
|
||||
status: 'planned' | 'in_progress' | 'complete';
|
||||
targetDate: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'todo' | 'in_progress' | 'done';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
due_date?: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
sectionId: string | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface Milestone {
|
||||
interface ProjectDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
due_date?: string;
|
||||
status: 'planned' | 'in_progress' | 'completed';
|
||||
completed_tasks: number;
|
||||
total_tasks: number;
|
||||
description: string | null;
|
||||
status: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
targetDate: string | null;
|
||||
sections: Section[];
|
||||
tasks: Task[];
|
||||
taskCount: number;
|
||||
completedCount: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
active: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
paused: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
completed: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
archived: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
|
||||
};
|
||||
|
||||
const taskStatusColors: Record<string, string> = {
|
||||
todo: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
in_progress: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200",
|
||||
done: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200",
|
||||
cancelled: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200",
|
||||
};
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.id as string;
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||
const [project, setProject] = useState<ProjectDetail | null>(null);
|
||||
const [domainId, setDomainId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [domainMap, setDomainMap] = useState<Map<string, string>>(new Map());
|
||||
const [sectionDialogOpen, setSectionDialogOpen] = useState(false);
|
||||
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchDomains();
|
||||
fetchProject();
|
||||
fetchTasks();
|
||||
fetchMilestones();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
async function fetchDomains() {
|
||||
// Extract domainId from the project data
|
||||
const fetchProject = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/domains?sort=sort_order');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const map = new Map<string, string>();
|
||||
for (const d of data.items || []) map.set(d.id, d.name);
|
||||
setDomainMap(map);
|
||||
// We need to find the domain first — use the first domain
|
||||
const domainsRes = await fetch('/api/domains?sort=sort_order');
|
||||
const domainsData = await domainsRes.json();
|
||||
const firstDomain = domainsData.items?.[0];
|
||||
if (!firstDomain) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setDomainId(firstDomain.id);
|
||||
|
||||
async function fetchProject() {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProject(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/tasks?filter=project_id%3D%22${projectId}%22&sort=-created`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTasks(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks:', error);
|
||||
const res = await fetch(`/api/domains/${firstDomain.id}/projects/${projectId}`);
|
||||
if (!res.ok) throw new Error('Not found');
|
||||
const data = await res.json();
|
||||
setProject(data);
|
||||
} catch {
|
||||
toast.error('Failed to load project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
async function fetchMilestones() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/milestones?filter=project_id%3D%22${projectId}%22&sort=due_date`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMilestones(data.items || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch milestones:', error);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchProject();
|
||||
}, [fetchProject]);
|
||||
|
||||
async function toggleTaskComplete(taskId: string, currentStatus: string) {
|
||||
const newStatus = currentStatus === 'done' ? 'todo' : 'done';
|
||||
const handleMoveTask = async (taskId: string, sectionId: string | null) => {
|
||||
try {
|
||||
await fetch(`/api/tasks/${taskId}`, {
|
||||
const res = await fetch(`/api/domains/${domainId}/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
body: JSON.stringify({ sectionId }),
|
||||
});
|
||||
fetchTasks();
|
||||
if (!res.ok) throw new Error('Failed to move task');
|
||||
toast.success('Task moved');
|
||||
fetchProject();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle task:', error);
|
||||
} catch {
|
||||
toast.error('Failed to move task');
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for custom event to open section dialog
|
||||
useEffect(() => {
|
||||
const handler = () => setSectionDialogOpen(true);
|
||||
document.addEventListener('open-create-section', handler);
|
||||
return () => document.removeEventListener('open-create-section', handler);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="py-12 text-center text-muted-foreground">Loading project...</div>;
|
||||
}
|
||||
|
||||
if (loading || !project) {
|
||||
return <p className="text-muted-foreground">Loading project...</p>;
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Project not found.</p>
|
||||
<Link href="/projects" className="mt-4 inline-block text-primary hover:underline">
|
||||
← Back to projects
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group tasks by section
|
||||
const tasksBySection = new Map<string | 'unsectioned', Task[]>();
|
||||
tasksBySection.set('unsectioned', []);
|
||||
for (const section of project.sections) {
|
||||
tasksBySection.set(section.id, []);
|
||||
}
|
||||
for (const task of project.tasks) {
|
||||
const key = task.sectionId || 'unsectioned';
|
||||
if (!tasksBySection.has(key)) tasksBySection.set(key, []);
|
||||
tasksBySection.get(key)!.push(task);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back button */}
|
||||
<Link href="/projects">
|
||||
<Button variant="ghost" size="sm" className="mb-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Back to projects
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Project header */}
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link href="/projects" className="mb-2 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
Back to projects
|
||||
</Link>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{project.name}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{project.color && (
|
||||
<div className="h-4 w-4 rounded-full shrink-0" style={{ backgroundColor: project.color }} />
|
||||
)}
|
||||
<h1 className="text-2xl font-bold">{project.name}</h1>
|
||||
<Badge variant="secondary" className={`text-xs ${statusColors[project.status] || ''}`}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="mt-1 text-muted-foreground">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
project.status === 'active'
|
||||
? 'default'
|
||||
: project.status === 'paused'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{project.status}
|
||||
</Badge>
|
||||
<Badge variant="outline">{domainMap.get(project.domain) || project.domain}</Badge>
|
||||
{project.targetDate && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Target: {new Date(project.targetDate).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project stats */}
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Progress</p>
|
||||
<p className="text-2xl font-bold">{project.progress}%</p>
|
||||
</div>
|
||||
<CheckCircle2 className="h-8 w-8 text-green-600" aria-hidden="true" />
|
||||
</div>
|
||||
<Progress value={project.progress} className="mt-2 h-2" aria-label={`${project.name} progress: ${project.progress}%`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Tasks</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{project.completed_count} / {project.task_count}
|
||||
</p>
|
||||
</div>
|
||||
<Circle className="h-8 w-8 text-blue-600" aria-hidden="true" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Due Date</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{project.due_date
|
||||
? new Date(project.due_date).toLocaleDateString()
|
||||
: 'No date'}
|
||||
</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-orange-600" aria-hidden="true" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mt-4 space-y-1">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{project.completedCount}/{project.taskCount} tasks completed</span>
|
||||
<span>{project.progress}%</span>
|
||||
</div>
|
||||
<Progress value={project.progress} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="tasks">
|
||||
<TabsList>
|
||||
<TabsTrigger value="tasks">Tasks ({tasks.length})</TabsTrigger>
|
||||
<TabsTrigger value="milestones">
|
||||
Milestones ({milestones.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="habits">Habits</TabsTrigger>
|
||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Sections board */}
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{/* Unsectioned tasks column */}
|
||||
<div className="min-w-[280px] max-w-[320px] flex-shrink-0">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Unassigned
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{(tasksBySection.get('unsectioned') || []).length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(tasksBySection.get('unsectioned') || []).map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={() => setDraggedTaskId(task.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (draggedTaskId && draggedTaskId !== task.id) {
|
||||
handleMoveTask(draggedTaskId, null);
|
||||
}
|
||||
setDraggedTaskId(null);
|
||||
}}
|
||||
className="rounded-lg border bg-card p-3 cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
|
||||
<span className="text-sm flex-1">{task.title}</span>
|
||||
<Badge variant="secondary" className={`text-xs ${taskStatusColors[task.status] || ''}`}>
|
||||
{task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(tasksBySection.get('unsectioned') || []).length === 0 && (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
Drop tasks here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="tasks" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Tasks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tasks.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No tasks yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 shrink-0"
|
||||
onClick={() =>
|
||||
toggleTaskComplete(task.id, task.status)
|
||||
}
|
||||
aria-label={`Mark "${task.title}" as ${task.status === 'done' ? 'incomplete' : 'complete'}`}
|
||||
>
|
||||
{task.status === 'done' ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
task.status === 'done'
|
||||
? 'text-muted-foreground line-through'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
task.priority === 'urgent'
|
||||
? 'destructive'
|
||||
: task.priority === 'high'
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
{task.due_date && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(task.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Section columns */}
|
||||
{project.sections.map((section) => (
|
||||
<div key={section.id} className="min-w-[280px] max-w-[320px] flex-shrink-0">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{section.name}
|
||||
</h3>
|
||||
{section.kind === 'milestone' && (
|
||||
<Badge variant="outline" className="text-xs">Milestone</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{(tasksBySection.get(section.id) || []).length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="space-y-2 min-h-[100px]"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => {
|
||||
if (draggedTaskId) {
|
||||
handleMoveTask(draggedTaskId, section.id);
|
||||
}
|
||||
setDraggedTaskId(null);
|
||||
}}
|
||||
>
|
||||
{(tasksBySection.get(section.id) || []).map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={() => setDraggedTaskId(task.id)}
|
||||
className="rounded-lg border bg-card p-3 cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
|
||||
<span className="text-sm flex-1">{task.title}</span>
|
||||
<Badge variant="secondary" className={`text-xs ${taskStatusColors[task.status] || ''}`}>
|
||||
{task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(tasksBySection.get(section.id) || []).length === 0 && (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
Drop tasks here
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<TabsContent value="milestones" className="mt-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Milestones</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{milestones.length === 0 ? (
|
||||
<p className="py-8 text-center text-muted-foreground">
|
||||
No milestones yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{milestones.map((milestone, index) => (
|
||||
<div key={milestone.id} className="relative flex gap-4">
|
||||
{/* Timeline line */}
|
||||
{index < milestones.length - 1 && (
|
||||
<div className="absolute left-5 top-12 h-full w-0.5 bg-border" />
|
||||
)}
|
||||
{/* Add section button */}
|
||||
<div className="min-w-[280px] max-w-[320px] flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setSectionDialogOpen(true)}
|
||||
className="flex h-full w-full items-center justify-center rounded-lg border-2 border-dashed p-4 text-sm text-muted-foreground hover:text-foreground hover:border-accent-foreground/50 transition-colors"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Add section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Milestone marker */}
|
||||
<div className="relative z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full border-2 bg-background">
|
||||
<Flag
|
||||
className={`h-5 w-5 ${
|
||||
milestone.status === 'completed'
|
||||
? 'text-green-600'
|
||||
: milestone.status === 'in_progress'
|
||||
? 'text-blue-600'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Milestone content */}
|
||||
<div className="flex-1 pb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
{milestone.name}
|
||||
</h2>
|
||||
{milestone.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{milestone.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
milestone.status === 'completed'
|
||||
? 'default'
|
||||
: milestone.status === 'in_progress'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{milestone.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{milestone.due_date && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Due:{' '}
|
||||
{new Date(
|
||||
milestone.due_date
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
Tasks
|
||||
</span>
|
||||
<span>
|
||||
{milestone.completed_tasks} /{' '}
|
||||
{milestone.total_tasks}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
milestone.total_tasks > 0
|
||||
? (milestone.completed_tasks /
|
||||
milestone.total_tasks) *
|
||||
100
|
||||
: 0
|
||||
}
|
||||
className="h-1.5"
|
||||
aria-label={`${milestone.name} task progress: ${milestone.completed_tasks} of ${milestone.total_tasks}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="habits" className="mt-6">
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Habits linked to this project will appear here
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes" className="mt-6">
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Notes linked to this project will appear here
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<SectionDialog
|
||||
open={sectionDialogOpen}
|
||||
onOpenChange={setSectionDialogOpen}
|
||||
projectId={projectId}
|
||||
domainId={domainId || ''}
|
||||
onCreated={fetchProject}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,110 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Plus, FolderKanban, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ProjectCreateDialog } from "@/components/projects/project-create-dialog";
|
||||
import Link from "next/link";
|
||||
import { CreateItemDialog } from "@/components/create-item-dialog";
|
||||
import { useCreateDialogStore } from "@/lib/stores/use-create-dialog-store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
status?: string;
|
||||
description: string | null;
|
||||
status: 'active' | 'paused' | 'completed' | 'archived';
|
||||
domainId: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
targetDate: string | null;
|
||||
taskCount: number;
|
||||
completedCount: number;
|
||||
progress: number;
|
||||
tags: { id: string; name: string; color: string | null }[];
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
active: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
paused: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
completed: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
archived: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200",
|
||||
};
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [domainMap, setDomainMap] = useState<Map<string, string>>(new Map());
|
||||
const [domainId, setDomainId] = useState<string | null>(null);
|
||||
const [domains, setDomains] = useState<{ id: string; name: string }[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const { open, openCreate, closeCreate } = useCreateDialogStore();
|
||||
|
||||
useEffect(() => { fetchProjects(); fetchDomains(); }, [refreshKey]);
|
||||
// Fetch domains
|
||||
useEffect(() => {
|
||||
fetch('/api/domains?sort=sort_order')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const items = data.items || [];
|
||||
setDomains(items);
|
||||
if (items.length > 0 && !domainId) {
|
||||
setDomainId(items[0].id);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchProjects() {
|
||||
// Fetch projects
|
||||
const fetchProjects = useCallback(async () => {
|
||||
if (!domainId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/projects?sort=-created");
|
||||
const res = await fetch(`/api/domains/${domainId}/projects`);
|
||||
const data = await res.json();
|
||||
setProjects(data.items || []);
|
||||
} catch { toast.error("Unable to load projects"); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load projects');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [domainId]);
|
||||
|
||||
async function fetchDomains() {
|
||||
try {
|
||||
const res = await fetch("/api/domains?sort=sort_order");
|
||||
const data = await res.json();
|
||||
const map = new Map<string, string>();
|
||||
for (const d of data.items || []) map.set(d.id, d.name);
|
||||
setDomainMap(map);
|
||||
} catch {}
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, [fetchProjects]);
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error();
|
||||
toast.success("Project deleted");
|
||||
setProjects((p) => p.filter((x) => x.id !== id));
|
||||
} catch { toast.error("Unable to delete project"); }
|
||||
finally { setDeleting(false); setDeleteId(null); }
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-muted-foreground">Loading projects...</p>;
|
||||
// Listen for custom event to open create dialog
|
||||
useEffect(() => {
|
||||
const handler = () => setCreateOpen(true);
|
||||
document.addEventListener('open-create-project', handler);
|
||||
return () => document.removeEventListener('open-create-project', handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Projects</h1>
|
||||
<p className="mt-1 text-muted-foreground">Plan and track your work.</p>
|
||||
<p className="mt-1 text-muted-foreground">Organize work into milestones and track progress.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{domains.length > 1 && (
|
||||
<select
|
||||
value={domainId || ''}
|
||||
onChange={(e) => setDomainId(e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
aria-label="Select domain"
|
||||
>
|
||||
{domains.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
New project
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => openCreate("project")}>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" /> New project
|
||||
</Button>
|
||||
</div>
|
||||
{projects.length === 0 ? <p className="text-muted-foreground">No projects yet.</p> : (
|
||||
<div key={refreshKey} className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => (
|
||||
<Card key={p.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<Link href={`/projects/${p.id}`} className="flex-1 text-left font-medium hover:underline">{p.name}</Link>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0 text-destructive" onClick={() => setDeleteId(p.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">{domainMap.get(p.domain) || p.domain}</Badge>
|
||||
{p.status && <Badge variant="secondary" className="text-xs">{p.status}</Badge>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-muted-foreground">Loading projects...</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<FolderKanban className="mx-auto h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
|
||||
<p className="mt-4 text-muted-foreground">No projects yet. Create your first one!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<Link key={project.id} href={`/projects/${project.id}`}>
|
||||
<Card className="h-full cursor-pointer transition-colors hover:bg-accent/50">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{project.color && (
|
||||
<div
|
||||
className="h-3 w-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: project.color }}
|
||||
/>
|
||||
)}
|
||||
<CardTitle className="text-base">{project.name}</CardTitle>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.description && (
|
||||
<p className="mb-3 text-sm text-muted-foreground line-clamp-2">{project.description}</p>
|
||||
)}
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Badge variant="secondary" className={`text-xs ${statusColors[project.status] || ''}`}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
{project.targetDate && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Due {new Date(project.targetDate).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{project.completedCount}/{project.taskCount} tasks</span>
|
||||
<span>{project.progress}%</span>
|
||||
</div>
|
||||
<Progress value={project.progress} className="h-2" />
|
||||
</div>
|
||||
{project.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{project.tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-xs"
|
||||
style={{ backgroundColor: tag.color || '#e2e8f0', color: '#1e293b' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project?</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>
|
||||
<CreateItemDialog type="project" open={open} onOpenChange={(o) => (o ? openCreate("project") : closeCreate())} onCreated={() => setRefreshKey((k) => k + 1)} />
|
||||
|
||||
<ProjectCreateDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
domainId={domainId || ''}
|
||||
onCreated={fetchProjects}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, habits, habitCompletions, sql } from '@project-e/db';
|
||||
import { and, eq, isNull, gte, desc, count } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const completeHabitSchema = z.object({
|
||||
value: z.number().int().positive().optional().default(1),
|
||||
mood: z.number().int().min(1).max(5).optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||
|
||||
/**
|
||||
* Calculate the current streak for a habit.
|
||||
* Streak = consecutive days with at least one completion, going backwards from today.
|
||||
* Skip days (e.g. weekends) are excluded from the streak count.
|
||||
*/
|
||||
async function calculateStreak(habitId: string, skipDays: number[]): Promise<number> {
|
||||
// Get all completion dates for this habit, ordered desc
|
||||
const completions = await db.select({ date: habitCompletions.date })
|
||||
.from(habitCompletions)
|
||||
.where(eq(habitCompletions.habitId, habitId))
|
||||
.orderBy(desc(habitCompletions.date));
|
||||
|
||||
if (completions.length === 0) return 0;
|
||||
|
||||
const completionDates = new Set(
|
||||
completions.map(c => c.date.toISOString().split('T')[0])
|
||||
);
|
||||
|
||||
let streak = 0;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const checkDate = new Date(today);
|
||||
|
||||
// Check up to 365 days back
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const dateStr = checkDate.toISOString().split('T')[0];
|
||||
const dayOfWeek = checkDate.getDay(); // 0=Sun, 6=Sat
|
||||
|
||||
if (skipDays.includes(dayOfWeek)) {
|
||||
// Skip day — move on without breaking streak
|
||||
checkDate.setDate(checkDate.getDate() - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (completionDates.has(dateStr)) {
|
||||
streak++;
|
||||
checkDate.setDate(checkDate.getDate() - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
// POST /api/domains/[domainId]/habits/[id]/complete — Complete a habit
|
||||
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = completeHabitSchema.parse(body);
|
||||
|
||||
// Verify habit exists
|
||||
const [habit] = await db.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!habit) {
|
||||
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
|
||||
}
|
||||
|
||||
// Create completion
|
||||
const [completion] = await db.insert(habitCompletions).values({
|
||||
habitId: id,
|
||||
date: new Date(),
|
||||
value: data.value,
|
||||
mood: data.mood ?? null,
|
||||
notes: data.notes ?? null,
|
||||
}).returning();
|
||||
|
||||
// Recalculate streak
|
||||
const skipDays = habit.skipDays || [];
|
||||
const newStreak = await calculateStreak(id, skipDays);
|
||||
|
||||
// Update habit with new streak
|
||||
const updateData: Record<string, unknown> = {
|
||||
streakCount: newStreak,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Update best streak if current is higher
|
||||
if (newStreak > (habit.bestStreak || 0)) {
|
||||
updateData.bestStreak = newStreak;
|
||||
}
|
||||
|
||||
await db.update(habits)
|
||||
.set(updateData)
|
||||
.where(eq(habits.id, id));
|
||||
|
||||
// Record activity
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'completed',
|
||||
entityType: 'habit',
|
||||
entityId: id,
|
||||
changes: { value: data.value, mood: data.mood, streak: newStreak },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
completion,
|
||||
streakCount: newStreak,
|
||||
bestStreak: Math.max(newStreak, habit.bestStreak || 0),
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[habit complete POST] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to complete habit', 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
|
||||
import { db, habits, habitCompletions } from '@project-e/db';
|
||||
import { and, asc, desc, eq, gte, isNull, lte } from 'drizzle-orm';
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/habits/[id]/completions — List completions with date range
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
// Verify habit exists
|
||||
const [habit] = await db.select({ id: habits.id })
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!habit) {
|
||||
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const from = searchParams.get('from');
|
||||
const to = searchParams.get('to');
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '365'), 1000);
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
const order = searchParams.get('order') || 'desc';
|
||||
|
||||
const conditions: any[] = [eq(habitCompletions.habitId, id)];
|
||||
|
||||
if (from) conditions.push(gte(habitCompletions.date, new Date(from)));
|
||||
if (to) conditions.push(lte(habitCompletions.date, new Date(to)));
|
||||
|
||||
const orderFn = order === 'asc' ? asc : desc;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db.select()
|
||||
.from(habitCompletions)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderFn(habitCompletions.date))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: db.$count(habitCompletions) })
|
||||
.from(habitCompletions)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
items,
|
||||
totalItems: Number(countResult[0]?.count || 0),
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, habits, habitCompletions, habitTags, tags as tagsTable } from '@project-e/db';
|
||||
import { and, asc, desc, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const habitFrequencyEnum = z.enum(['daily', 'weekly', 'custom']);
|
||||
const habitDifficultyEnum = z.enum(['easy', 'medium', 'hard']);
|
||||
|
||||
const updateHabitSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
frequency: habitFrequencyEnum.optional(),
|
||||
difficulty: habitDifficultyEnum.optional(),
|
||||
goalPerPeriod: z.number().int().positive().optional(),
|
||||
unit: z.string().optional().nullable(),
|
||||
reminderTime: z.string().optional().nullable(),
|
||||
skipDays: z.array(z.number().int().min(0).max(6)).optional(),
|
||||
moodTracking: z.boolean().optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/habits/[id] — Get a single habit with streak + recent completions
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const [habit] = await db.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!habit) {
|
||||
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
|
||||
}
|
||||
|
||||
// Fetch recent completions (last 30 days)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const recentCompletions = await db.select()
|
||||
.from(habitCompletions)
|
||||
.where(and(
|
||||
eq(habitCompletions.habitId, id),
|
||||
gte(habitCompletions.date, thirtyDaysAgo),
|
||||
))
|
||||
.orderBy(desc(habitCompletions.date));
|
||||
|
||||
// Fetch tags
|
||||
const tagRows = await db.select({
|
||||
id: tagsTable.id,
|
||||
name: tagsTable.name,
|
||||
color: tagsTable.color,
|
||||
})
|
||||
.from(habitTags)
|
||||
.innerJoin(tagsTable, eq(habitTags.tagId, tagsTable.id))
|
||||
.where(eq(habitTags.habitId, id));
|
||||
|
||||
return NextResponse.json({
|
||||
...habit,
|
||||
recentCompletions,
|
||||
tags: tagRows,
|
||||
});
|
||||
});
|
||||
|
||||
// PATCH /api/domains/[domainId]/habits/[id] — Update a habit
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = updateHabitSchema.parse(body);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
|
||||
}
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateValues.name = data.name;
|
||||
if (data.description !== undefined) updateValues.description = data.description;
|
||||
if (data.frequency !== undefined) updateValues.frequency = data.frequency;
|
||||
if (data.difficulty !== undefined) updateValues.difficulty = data.difficulty;
|
||||
if (data.goalPerPeriod !== undefined) updateValues.goalPerPeriod = data.goalPerPeriod;
|
||||
if (data.unit !== undefined) updateValues.unit = data.unit;
|
||||
if (data.reminderTime !== undefined) updateValues.reminderTime = data.reminderTime;
|
||||
if (data.skipDays !== undefined) updateValues.skipDays = data.skipDays;
|
||||
if (data.moodTracking !== undefined) updateValues.moodTracking = data.moodTracking;
|
||||
if (data.active !== undefined) updateValues.active = data.active;
|
||||
updateValues.updatedAt = new Date();
|
||||
|
||||
const [updated] = await db.update(habits)
|
||||
.set(updateValues)
|
||||
.where(eq(habits.id, id))
|
||||
.returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'updated',
|
||||
entityType: 'habit',
|
||||
entityId: id,
|
||||
changes: { ...data, previousName: existing.name },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[habits PATCH] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to update habit', 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/domains/[domainId]/habits/[id] — Soft delete a habit
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
|
||||
}
|
||||
|
||||
await db.update(habits)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(habits.id, id));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'deleted',
|
||||
entityType: 'habit',
|
||||
entityId: id,
|
||||
changes: { name: existing.name },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, habits, habitTags, tags as tagsTable } from '@project-e/db';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const tagActionSchema = z.object({
|
||||
tagId: z.string().uuid(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||
|
||||
// POST /api/domains/[domainId]/habits/[id]/tags — Add a tag to a habit
|
||||
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = tagActionSchema.parse(body);
|
||||
|
||||
// Verify habit exists
|
||||
const [habit] = await db.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, id), eq(habits.domainId, domainId), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!habit) {
|
||||
return createErrorResponse('NOT_FOUND', 'Habit not found', 404);
|
||||
}
|
||||
|
||||
// Verify tag exists
|
||||
const [tag] = await db.select()
|
||||
.from(tagsTable)
|
||||
.where(eq(tagsTable.id, data.tagId))
|
||||
.limit(1);
|
||||
|
||||
if (!tag) {
|
||||
return createErrorResponse('NOT_FOUND', 'Tag not found', 404);
|
||||
}
|
||||
|
||||
// Check if already tagged
|
||||
const [existing] = await db.select()
|
||||
.from(habitTags)
|
||||
.where(and(eq(habitTags.habitId, id), eq(habitTags.tagId, data.tagId)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return createErrorResponse('CONFLICT', 'Tag already added to this habit', 409);
|
||||
}
|
||||
|
||||
await db.insert(habitTags).values({ habitId: id, tagId: data.tagId });
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'tag_added',
|
||||
entityType: 'habit',
|
||||
entityId: id,
|
||||
changes: { tagId: data.tagId, tagName: tag.name },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[habit tags POST] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to add tag', 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/domains/[domainId]/habits/[id]/tags — Remove a tag from a habit
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = tagActionSchema.parse(body);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(habitTags)
|
||||
.where(and(eq(habitTags.habitId, id), eq(habitTags.tagId, data.tagId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return createErrorResponse('NOT_FOUND', 'Tag not found on this habit', 404);
|
||||
}
|
||||
|
||||
await db.delete(habitTags)
|
||||
.where(and(eq(habitTags.habitId, id), eq(habitTags.tagId, data.tagId)));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'tag_removed',
|
||||
entityType: 'habit',
|
||||
entityId: id,
|
||||
changes: { tagId: data.tagId },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[habit tags DELETE] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to remove tag', 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, habits, habitTags, tags as tagsTable } from '@project-e/db';
|
||||
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const habitFrequencyEnum = z.enum(['daily', 'weekly', 'custom']);
|
||||
const habitDifficultyEnum = z.enum(['easy', 'medium', 'hard']);
|
||||
|
||||
const createHabitSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional().nullable(),
|
||||
frequency: habitFrequencyEnum.optional().default('daily'),
|
||||
difficulty: habitDifficultyEnum.optional().default('medium'),
|
||||
goalPerPeriod: z.number().int().positive().optional().default(1),
|
||||
unit: z.string().optional().nullable(),
|
||||
reminderTime: z.string().optional().nullable(),
|
||||
skipDays: z.array(z.number().int().min(0).max(6)).optional().default([]),
|
||||
moodTracking: z.boolean().optional().default(false),
|
||||
active: z.boolean().optional().default(true),
|
||||
tagIds: z.array(z.string().uuid()).optional(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/habits — List habits with filtering
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const active = searchParams.get('active');
|
||||
const frequency = searchParams.get('frequency');
|
||||
const difficulty = searchParams.get('difficulty');
|
||||
const search = searchParams.get('search');
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
const sort = searchParams.get('sort') || 'name';
|
||||
const order = searchParams.get('order') || 'asc';
|
||||
|
||||
const conditions: any[] = [
|
||||
eq(habits.domainId, domainId),
|
||||
isNull(habits.deletedAt),
|
||||
];
|
||||
|
||||
if (active === 'true') conditions.push(eq(habits.active, true));
|
||||
else if (active === 'false') conditions.push(eq(habits.active, false));
|
||||
if (frequency) conditions.push(eq(habits.frequency, frequency as any));
|
||||
if (difficulty) conditions.push(eq(habits.difficulty, difficulty as any));
|
||||
if (search) conditions.push(ilike(habits.name, `%${search}%`));
|
||||
|
||||
const orderFn = order === 'desc' ? desc : asc;
|
||||
let orderColumn;
|
||||
switch (sort) {
|
||||
case 'frequency': orderColumn = orderFn(habits.frequency); break;
|
||||
case 'difficulty': orderColumn = orderFn(habits.difficulty); break;
|
||||
case 'streak_count': orderColumn = orderFn(habits.streakCount); break;
|
||||
case 'created_at': orderColumn = orderFn(habits.createdAt); break;
|
||||
case 'updated_at': orderColumn = orderFn(habits.updatedAt); break;
|
||||
default: orderColumn = orderFn(habits.name); break;
|
||||
}
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db.select()
|
||||
.from(habits)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderColumn)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: sql<number>`count(*)` })
|
||||
.from(habits)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
const totalItems = Number(countResult[0]?.count || 0);
|
||||
|
||||
// Fetch tags for all habits
|
||||
let habitTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
||||
if (items.length > 0) {
|
||||
const habitIds = items.map(h => h.id);
|
||||
const tagRows = await db.select({
|
||||
habitId: habitTags.habitId,
|
||||
id: tagsTable.id,
|
||||
name: tagsTable.name,
|
||||
color: tagsTable.color,
|
||||
})
|
||||
.from(habitTags)
|
||||
.innerJoin(tagsTable, eq(habitTags.tagId, tagsTable.id))
|
||||
.where(inArray(habitTags.habitId, habitIds));
|
||||
|
||||
for (const row of tagRows) {
|
||||
if (!habitTagMap.has(row.habitId)) habitTagMap.set(row.habitId, []);
|
||||
habitTagMap.get(row.habitId)!.push({ id: row.id, name: row.name, color: row.color });
|
||||
}
|
||||
}
|
||||
|
||||
const itemsWithTags = items.map(h => ({
|
||||
...h,
|
||||
tags: habitTagMap.get(h.id) || [],
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
items: itemsWithTags,
|
||||
totalItems,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/domains/[domainId]/habits — Create a habit
|
||||
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createHabitSchema.parse(body);
|
||||
|
||||
const [habit] = await db.insert(habits).values({
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
domainId,
|
||||
frequency: data.frequency,
|
||||
difficulty: data.difficulty,
|
||||
goalPerPeriod: data.goalPerPeriod,
|
||||
unit: data.unit ?? null,
|
||||
reminderTime: data.reminderTime ?? null,
|
||||
skipDays: data.skipDays,
|
||||
moodTracking: data.moodTracking,
|
||||
active: data.active,
|
||||
}).returning();
|
||||
|
||||
// Insert tags if provided
|
||||
if (data.tagIds && data.tagIds.length > 0) {
|
||||
await db.insert(habitTags).values(
|
||||
data.tagIds.map(tagId => ({ habitId: habit.id, tagId }))
|
||||
);
|
||||
}
|
||||
|
||||
// Record activity
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'created',
|
||||
entityType: 'habit',
|
||||
entityId: habit.id,
|
||||
changes: { name: habit.name, frequency: habit.frequency, difficulty: habit.difficulty },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json(habit, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[habits POST] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to create habit', 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, projects, tasks, sections, projectTags, tags as tagsTable } from '@project-e/db';
|
||||
import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const projectStatusEnum = z.enum(['active', 'paused', 'completed', 'archived']);
|
||||
|
||||
const updateProjectSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
status: projectStatusEnum.optional(),
|
||||
color: z.string().optional().nullable(),
|
||||
icon: z.string().optional().nullable(),
|
||||
targetDate: z.string().datetime().optional().nullable(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/projects/[id] — Get a single project with sections, task counts, progress
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const [project] = await db.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, id), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!project) {
|
||||
return createErrorResponse('NOT_FOUND', 'Project not found', 404);
|
||||
}
|
||||
|
||||
// Fetch sections
|
||||
const projectSections = await db.select()
|
||||
.from(sections)
|
||||
.where(eq(sections.projectId, id))
|
||||
.orderBy(asc(sections.sortOrder));
|
||||
|
||||
// Fetch tasks grouped by section
|
||||
const projectTasks = await db.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.projectId, id), isNull(tasks.deletedAt)))
|
||||
.orderBy(asc(tasks.order));
|
||||
|
||||
// Fetch tags
|
||||
const tagRows = await db.select({
|
||||
id: tagsTable.id,
|
||||
name: tagsTable.name,
|
||||
color: tagsTable.color,
|
||||
})
|
||||
.from(projectTags)
|
||||
.innerJoin(tagsTable, eq(projectTags.tagId, tagsTable.id))
|
||||
.where(eq(projectTags.projectId, id));
|
||||
|
||||
// Compute counts
|
||||
const totalTasks = projectTasks.length;
|
||||
const completedTasks = projectTasks.filter(t => t.status === 'done').length;
|
||||
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
||||
|
||||
return NextResponse.json({
|
||||
...project,
|
||||
sections: projectSections,
|
||||
tasks: projectTasks,
|
||||
tags: tagRows,
|
||||
taskCount: totalTasks,
|
||||
completedCount: completedTasks,
|
||||
progress,
|
||||
});
|
||||
});
|
||||
|
||||
// PATCH /api/domains/[domainId]/projects/[id] — Update a project
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = updateProjectSchema.parse(body);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, id), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return createErrorResponse('NOT_FOUND', 'Project not found', 404);
|
||||
}
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateValues.name = data.name;
|
||||
if (data.description !== undefined) updateValues.description = data.description;
|
||||
if (data.status !== undefined) updateValues.status = data.status;
|
||||
if (data.color !== undefined) updateValues.color = data.color;
|
||||
if (data.icon !== undefined) updateValues.icon = data.icon;
|
||||
if (data.targetDate !== undefined) updateValues.targetDate = data.targetDate ? new Date(data.targetDate) : null;
|
||||
updateValues.updatedAt = new Date();
|
||||
|
||||
const [updated] = await db.update(projects)
|
||||
.set(updateValues)
|
||||
.where(eq(projects.id, id))
|
||||
.returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'updated',
|
||||
entityType: 'project',
|
||||
entityId: id,
|
||||
changes: { ...data, previousName: existing.name },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[projects PATCH] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to update project', 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/domains/[domainId]/projects/[id] — Soft delete a project
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, id), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return createErrorResponse('NOT_FOUND', 'Project not found', 404);
|
||||
}
|
||||
|
||||
await db.update(projects)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(projects.id, id));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'deleted',
|
||||
entityType: 'project',
|
||||
entityId: id,
|
||||
changes: { name: existing.name },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, projects, sections } from '@project-e/db';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const sectionKindEnum = z.enum(['section', 'milestone']);
|
||||
const sectionStatusEnum = z.enum(['planned', 'in_progress', 'complete']);
|
||||
|
||||
const updateSectionSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
kind: sectionKindEnum.optional(),
|
||||
status: sectionStatusEnum.optional(),
|
||||
targetDate: z.string().datetime().optional().nullable(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string; projectId: string; id: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/projects/[projectId]/sections/[id] — Get a single section
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, projectId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const [section] = await db.select()
|
||||
.from(sections)
|
||||
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
|
||||
.limit(1);
|
||||
|
||||
if (!section) {
|
||||
return createErrorResponse('NOT_FOUND', 'Section not found', 404);
|
||||
}
|
||||
|
||||
return NextResponse.json(section);
|
||||
});
|
||||
|
||||
// PATCH /api/domains/[domainId]/projects/[projectId]/sections/[id] — Update a section
|
||||
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, projectId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = updateSectionSchema.parse(body);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(sections)
|
||||
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return createErrorResponse('NOT_FOUND', 'Section not found', 404);
|
||||
}
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateValues.name = data.name;
|
||||
if (data.kind !== undefined) updateValues.kind = data.kind;
|
||||
if (data.status !== undefined) updateValues.status = data.status;
|
||||
if (data.targetDate !== undefined) updateValues.targetDate = data.targetDate ? new Date(data.targetDate) : null;
|
||||
if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder;
|
||||
updateValues.updatedAt = new Date();
|
||||
|
||||
const [updated] = await db.update(sections)
|
||||
.set(updateValues)
|
||||
.where(eq(sections.id, id))
|
||||
.returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'updated',
|
||||
entityType: 'section',
|
||||
entityId: id,
|
||||
changes: { ...data, previousName: existing.name, projectId },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[sections PATCH] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to update section', 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/domains/[domainId]/projects/[projectId]/sections/[id] — Delete a section
|
||||
export const DELETE = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, projectId, id } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(sections)
|
||||
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return createErrorResponse('NOT_FOUND', 'Section not found', 404);
|
||||
}
|
||||
|
||||
await db.delete(sections)
|
||||
.where(eq(sections.id, id));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'deleted',
|
||||
entityType: 'section',
|
||||
entityId: id,
|
||||
changes: { name: existing.name, projectId },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, projects, sections } from '@project-e/db';
|
||||
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const sectionKindEnum = z.enum(['section', 'milestone']);
|
||||
const sectionStatusEnum = z.enum(['planned', 'in_progress', 'complete']);
|
||||
|
||||
const createSectionSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
kind: sectionKindEnum.optional().default('section'),
|
||||
status: sectionStatusEnum.optional().default('planned'),
|
||||
targetDate: z.string().datetime().optional().nullable(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string; projectId: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/projects/[projectId]/sections — List sections for a project
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, projectId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
// Verify project exists and belongs to domain
|
||||
const [project] = await db.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, projectId), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!project) {
|
||||
return createErrorResponse('NOT_FOUND', 'Project not found', 404);
|
||||
}
|
||||
|
||||
const items = await db.select()
|
||||
.from(sections)
|
||||
.where(eq(sections.projectId, projectId))
|
||||
.orderBy(asc(sections.sortOrder));
|
||||
|
||||
return NextResponse.json({ items });
|
||||
});
|
||||
|
||||
// POST /api/domains/[domainId]/projects/[projectId]/sections — Create a section
|
||||
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId, projectId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createSectionSchema.parse(body);
|
||||
|
||||
// Verify project exists
|
||||
const [project] = await db.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, projectId), eq(projects.domainId, domainId), isNull(projects.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!project) {
|
||||
return createErrorResponse('NOT_FOUND', 'Project not found', 404);
|
||||
}
|
||||
|
||||
// Determine sort order if not provided
|
||||
let sortOrder = data.sortOrder;
|
||||
if (sortOrder === undefined) {
|
||||
const [maxOrder] = await db.select({ max: sql<number>`COALESCE(MAX(sort_order), -1)` })
|
||||
.from(sections)
|
||||
.where(eq(sections.projectId, projectId));
|
||||
sortOrder = Number(maxOrder?.max || -1) + 1;
|
||||
}
|
||||
|
||||
const [section] = await db.insert(sections).values({
|
||||
name: data.name,
|
||||
projectId,
|
||||
kind: data.kind,
|
||||
status: data.status,
|
||||
targetDate: data.targetDate ? new Date(data.targetDate) : null,
|
||||
sortOrder,
|
||||
}).returning();
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'created',
|
||||
entityType: 'section',
|
||||
entityId: section.id,
|
||||
changes: { name: section.name, projectId, projectName: project.name, kind: section.kind },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json(section, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[sections POST] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to create section', 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||
// 1. Insert activity feed entry
|
||||
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||
import { recordActivity } from '@/lib/activity';
|
||||
import { db, projects, tasks, sections, projectTags, tags as tagsTable } from '@project-e/db';
|
||||
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const projectStatusEnum = z.enum(['active', 'paused', 'completed', 'archived']);
|
||||
|
||||
const createProjectSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional().nullable(),
|
||||
status: projectStatusEnum.optional().default('active'),
|
||||
color: z.string().optional().nullable(),
|
||||
icon: z.string().optional().nullable(),
|
||||
targetDate: z.string().datetime().optional().nullable(),
|
||||
tagIds: z.array(z.string().uuid()).optional(),
|
||||
});
|
||||
|
||||
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||
|
||||
// GET /api/domains/[domainId]/projects — List projects with filtering
|
||||
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get('status');
|
||||
const search = searchParams.get('search');
|
||||
const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
const sort = searchParams.get('sort') || 'name';
|
||||
const order = searchParams.get('order') || 'asc';
|
||||
|
||||
const conditions: any[] = [
|
||||
eq(projects.domainId, domainId),
|
||||
isNull(projects.deletedAt),
|
||||
];
|
||||
|
||||
if (status) {
|
||||
const statuses = status.split(',');
|
||||
conditions.push(inArray(projects.status, statuses as any));
|
||||
}
|
||||
if (search) conditions.push(ilike(projects.name, `%${search}%`));
|
||||
|
||||
const orderFn = order === 'desc' ? desc : asc;
|
||||
let orderColumn;
|
||||
switch (sort) {
|
||||
case 'status': orderColumn = orderFn(projects.status); break;
|
||||
case 'target_date': orderColumn = orderFn(projects.targetDate); break;
|
||||
case 'created_at': orderColumn = orderFn(projects.createdAt); break;
|
||||
case 'updated_at': orderColumn = orderFn(projects.updatedAt); break;
|
||||
default: orderColumn = orderFn(projects.name); break;
|
||||
}
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db.select()
|
||||
.from(projects)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderColumn)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: sql<number>`count(*)` })
|
||||
.from(projects)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
const totalItems = Number(countResult[0]?.count || 0);
|
||||
|
||||
// Fetch task counts and tags for all projects
|
||||
let projectTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
||||
let taskCountMap = new Map<string, { total: number; completed: number }>();
|
||||
|
||||
if (items.length > 0) {
|
||||
const projectIds = items.map(p => p.id);
|
||||
|
||||
// Tags
|
||||
const tagRows = await db.select({
|
||||
projectId: projectTags.projectId,
|
||||
id: tagsTable.id,
|
||||
name: tagsTable.name,
|
||||
color: tagsTable.color,
|
||||
})
|
||||
.from(projectTags)
|
||||
.innerJoin(tagsTable, eq(projectTags.tagId, tagsTable.id))
|
||||
.where(inArray(projectTags.projectId, projectIds));
|
||||
|
||||
for (const row of tagRows) {
|
||||
if (!projectTagMap.has(row.projectId)) projectTagMap.set(row.projectId, []);
|
||||
projectTagMap.get(row.projectId)!.push({ id: row.id, name: row.name, color: row.color });
|
||||
}
|
||||
|
||||
// Task counts
|
||||
for (const projectId of projectIds) {
|
||||
const [totalResult] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.projectId, projectId), isNull(tasks.deletedAt)));
|
||||
|
||||
const [completedResult] = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.projectId, projectId), eq(tasks.status, 'done'), isNull(tasks.deletedAt)));
|
||||
|
||||
taskCountMap.set(projectId, {
|
||||
total: Number(totalResult?.count || 0),
|
||||
completed: Number(completedResult?.count || 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const itemsWithMeta = items.map(p => {
|
||||
const counts = taskCountMap.get(p.id) || { total: 0, completed: 0 };
|
||||
return {
|
||||
...p,
|
||||
tags: projectTagMap.get(p.id) || [],
|
||||
taskCount: counts.total,
|
||||
completedCount: counts.completed,
|
||||
progress: counts.total > 0 ? Math.round((counts.completed / counts.total) * 100) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
items: itemsWithMeta,
|
||||
totalItems,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/domains/[domainId]/projects — Create a project
|
||||
export const POST = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||
const { domainId } = await context!.params;
|
||||
await requireWorkspaceAccess(domainId);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createProjectSchema.parse(body);
|
||||
|
||||
const [project] = await db.insert(projects).values({
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
status: data.status,
|
||||
domainId,
|
||||
color: data.color ?? null,
|
||||
icon: data.icon ?? null,
|
||||
targetDate: data.targetDate ? new Date(data.targetDate) : null,
|
||||
}).returning();
|
||||
|
||||
// Insert tags if provided
|
||||
if (data.tagIds && data.tagIds.length > 0) {
|
||||
await db.insert(projectTags).values(
|
||||
data.tagIds.map(tagId => ({ projectId: project.id, tagId }))
|
||||
);
|
||||
}
|
||||
|
||||
// Record activity
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: 'created',
|
||||
entityType: 'project',
|
||||
entityId: project.id,
|
||||
changes: { name: project.name, status: project.status },
|
||||
workspaceId: domainId,
|
||||
});
|
||||
|
||||
return NextResponse.json(project, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return createErrorResponse(error.code, error.message, error.status);
|
||||
}
|
||||
console.error('[projects POST] error:', error);
|
||||
return createErrorResponse('INTERNAL_ERROR', 'Failed to create project', 500);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user