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:
2026-07-29 06:37:37 -04:00
parent 1fcd12fc14
commit 064a46f97d
23 changed files with 3633 additions and 523 deletions
+243
View File
@@ -0,0 +1,243 @@
/**
* API tests for habits routes.
* These tests verify the habit CRUD API logic using mocked Drizzle.
* Run with: npm test -- --testPathPattern=habits
*/
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
// Mock the database module
jest.mock('@project-e/db', () => ({
db: {
select: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
sql: { unsafe: jest.fn() },
habits: {},
habitCompletions: {},
habitTags: {},
tags: {},
activityFeed: {},
}));
jest.mock('@/lib/auth', () => ({
withAuth: (handler: any) => {
return (request: any, context: any) => {
const mockUser = { id: 'user-1', email: 'test@test.com', name: 'Test User' };
return handler(request, mockUser, context);
};
},
requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined),
createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({
code,
message,
status,
details,
}),
ApiError: class ApiError extends Error {
constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') {
super(message);
}
},
}));
jest.mock('@/lib/activity', () => ({
recordActivity: jest.fn().mockResolvedValue(undefined),
}));
// Build a Drizzle-like chain that resolves to the given value when awaited
function chain(resolvedValue: any) {
const c: any = {};
c.from = jest.fn().mockReturnValue(c);
c.where = jest.fn().mockReturnValue(c);
c.orderBy = jest.fn().mockReturnValue(c);
c.limit = jest.fn().mockReturnValue(c);
c.offset = jest.fn().mockReturnValue(c);
c.innerJoin = jest.fn().mockReturnValue(c);
c.having = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
return c;
}
function insertChain(resolvedValue: any) {
const c: any = {};
c.values = jest.fn().mockReturnValue(c);
c.returning = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
return c;
}
function updateChain(resolvedValue?: any) {
const c: any = {};
c.set = jest.fn().mockReturnValue(c);
c.where = jest.fn().mockReturnValue(c);
c.returning = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).catch(onrejected);
return c;
}
describe('Habits API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/domains/[domainId]/habits', () => {
it('should list habits with default pagination', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/habits/route');
const { db } = require('@project-e/db');
const mockHabits = [
{ id: '1', name: 'Habit 1', frequency: 'daily', difficulty: 'medium', domainId: 'domain-1', streakCount: 5, bestStreak: 10 },
{ id: '2', name: 'Habit 2', frequency: 'weekly', difficulty: 'hard', domainId: 'domain-1', streakCount: 0, bestStreak: 3 },
];
db.select
.mockReturnValueOnce(chain(mockHabits)) // main query
.mockReturnValueOnce(chain([{ count: 2 }])) // count query
.mockReturnValueOnce(chain([])); // tags query
const request = new Request('http://localhost:3000/api/domains/domain-1/habits');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
it('should filter by active status', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/habits/route');
const { db } = require('@project-e/db');
db.select
.mockReturnValueOnce(chain([{ id: '1', name: 'Habit 1', domainId: 'domain-1' }]))
.mockReturnValueOnce(chain([{ count: 1 }]))
.mockReturnValueOnce(chain([]));
const request = new Request('http://localhost:3000/api/domains/domain-1/habits?active=true');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
});
describe('POST /api/domains/[domainId]/habits', () => {
it('should create a habit with required fields', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/habits/route');
const { db } = require('@project-e/db');
const mockHabit = {
id: 'new-habit-1',
name: 'Test Habit',
frequency: 'daily',
difficulty: 'medium',
domainId: 'domain-1',
};
db.insert.mockReturnValue(insertChain([mockHabit]));
const request = new Request('http://localhost:3000/api/domains/domain-1/habits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test Habit' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
it('should reject empty name', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/habits/route');
const request = new Request('http://localhost:3000/api/domains/domain-1/habits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
});
describe('POST /api/domains/[domainId]/habits/[id]/complete', () => {
it('should complete a habit and return streak info', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/habits/[id]/complete/route');
const { db } = require('@project-e/db');
const mockHabit = {
id: 'habit-1',
name: 'Test Habit',
domainId: 'domain-1',
skipDays: [0, 6],
streakCount: 3,
bestStreak: 10,
};
db.select
.mockReturnValueOnce(chain([mockHabit])) // verify habit exists
.mockReturnValueOnce(chain([])); // completions for streak calc
db.insert.mockReturnValue(insertChain([{ id: 'comp-1', habitId: 'habit-1', date: new Date(), value: 1 }]));
db.update.mockReturnValue(updateChain());
const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: 1 }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) });
expect(response).toBeDefined();
});
});
describe('PATCH /api/domains/[domainId]/habits/[id]', () => {
it('should update a habit', async () => {
const { PATCH } = await import('@/app/api/domains/[domainId]/habits/[id]/route');
const { db } = require('@project-e/db');
const existingHabit = {
id: 'habit-1',
name: 'Test Habit',
domainId: 'domain-1',
};
db.select.mockReturnValue(chain([existingHabit]));
db.update.mockReturnValue(updateChain([{ ...existingHabit, name: 'Updated Habit' }]));
const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Updated Habit' }),
});
const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) });
expect(response).toBeDefined();
});
});
describe('DELETE /api/domains/[domainId]/habits/[id]', () => {
it('should soft delete a habit', async () => {
const { DELETE } = await import('@/app/api/domains/[domainId]/habits/[id]/route');
const { db } = require('@project-e/db');
const existingHabit = {
id: 'habit-1',
name: 'Test Habit',
domainId: 'domain-1',
};
db.select.mockReturnValue(chain([existingHabit]));
db.update.mockReturnValue(updateChain());
const request = new Request('http://localhost:3000/api/domains/domain-1/habits/habit-1', {
method: 'DELETE',
});
const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'habit-1' }) });
expect(response.status).toBe(204);
});
});
});
+273
View File
@@ -0,0 +1,273 @@
/**
* API tests for projects and sections routes.
* These tests verify the project CRUD and section API logic using mocked Drizzle.
* Run with: npm test -- --testPathPattern=projects
*/
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
// Mock the database module
jest.mock('@project-e/db', () => ({
db: {
select: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
sql: { unsafe: jest.fn() },
projects: {},
sections: {},
tasks: {},
projectTags: {},
tags: {},
activityFeed: {},
}));
jest.mock('@/lib/auth', () => ({
withAuth: (handler: any) => {
return (request: any, context: any) => {
const mockUser = { id: 'user-1', email: 'test@test.com', name: 'Test User' };
return handler(request, mockUser, context);
};
},
requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined),
createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({
code,
message,
status,
details,
}),
ApiError: class ApiError extends Error {
constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') {
super(message);
}
},
}));
jest.mock('@/lib/activity', () => ({
recordActivity: jest.fn().mockResolvedValue(undefined),
}));
// Build a Drizzle-like chain that resolves to the given value when awaited
function chain(resolvedValue: any) {
const c: any = {};
c.from = jest.fn().mockReturnValue(c);
c.where = jest.fn().mockReturnValue(c);
c.orderBy = jest.fn().mockReturnValue(c);
c.limit = jest.fn().mockReturnValue(c);
c.offset = jest.fn().mockReturnValue(c);
c.innerJoin = jest.fn().mockReturnValue(c);
c.having = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
return c;
}
function insertChain(resolvedValue: any) {
const c: any = {};
c.values = jest.fn().mockReturnValue(c);
c.returning = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue).catch(onrejected);
return c;
}
function updateChain(resolvedValue?: any) {
const c: any = {};
c.set = jest.fn().mockReturnValue(c);
c.where = jest.fn().mockReturnValue(c);
c.returning = jest.fn().mockReturnValue(c);
c.then = (onfulfilled: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).then(onfulfilled);
c.catch = (onrejected: any) => Promise.resolve(resolvedValue !== undefined ? resolvedValue : undefined).catch(onrejected);
return c;
}
describe('Projects API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/domains/[domainId]/projects', () => {
it('should list projects with default pagination', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/projects/route');
const { db } = require('@project-e/db');
const mockProjects = [
{ id: '1', name: 'Project 1', status: 'active', domainId: 'domain-1' },
{ id: '2', name: 'Project 2', status: 'paused', domainId: 'domain-1' },
];
db.select
.mockReturnValueOnce(chain(mockProjects)) // main query
.mockReturnValueOnce(chain([{ count: 2 }])) // count query
.mockReturnValueOnce(chain([])) // tags query
.mockReturnValueOnce(chain([{ count: 0 }])) // task count for project 1
.mockReturnValueOnce(chain([{ count: 0 }])) // completed count for project 1
.mockReturnValueOnce(chain([{ count: 0 }])) // task count for project 2
.mockReturnValueOnce(chain([{ count: 0 }])); // completed count for project 2
const request = new Request('http://localhost:3000/api/domains/domain-1/projects');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
it('should filter by status', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/projects/route');
const { db } = require('@project-e/db');
db.select
.mockReturnValueOnce(chain([{ id: '1', name: 'Project 1', domainId: 'domain-1' }]))
.mockReturnValueOnce(chain([{ count: 1 }]))
.mockReturnValueOnce(chain([]))
.mockReturnValueOnce(chain([{ count: 0 }]))
.mockReturnValueOnce(chain([{ count: 0 }]));
const request = new Request('http://localhost:3000/api/domains/domain-1/projects?status=active');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
});
describe('POST /api/domains/[domainId]/projects', () => {
it('should create a project with required fields', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/projects/route');
const { db } = require('@project-e/db');
const mockProject = {
id: 'new-project-1',
name: 'Test Project',
status: 'active',
domainId: 'domain-1',
};
db.insert.mockReturnValue(insertChain([mockProject]));
const request = new Request('http://localhost:3000/api/domains/domain-1/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test Project' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
it('should reject empty name', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/projects/route');
const request = new Request('http://localhost:3000/api/domains/domain-1/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
expect(response).toBeDefined();
});
});
describe('PATCH /api/domains/[domainId]/projects/[id]', () => {
it('should update a project', async () => {
const { PATCH } = await import('@/app/api/domains/[domainId]/projects/[id]/route');
const { db } = require('@project-e/db');
const existingProject = {
id: 'project-1',
name: 'Test Project',
domainId: 'domain-1',
};
db.select.mockReturnValue(chain([existingProject]));
db.update.mockReturnValue(updateChain([{ ...existingProject, name: 'Updated Project' }]));
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Updated Project' }),
});
const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'project-1' }) });
expect(response).toBeDefined();
});
});
describe('DELETE /api/domains/[domainId]/projects/[id]', () => {
it('should soft delete a project', async () => {
const { DELETE } = await import('@/app/api/domains/[domainId]/projects/[id]/route');
const { db } = require('@project-e/db');
const existingProject = {
id: 'project-1',
name: 'Test Project',
domainId: 'domain-1',
};
db.select.mockReturnValue(chain([existingProject]));
db.update.mockReturnValue(updateChain());
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1', {
method: 'DELETE',
});
const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'project-1' }) });
expect(response.status).toBe(204);
});
});
});
describe('Sections API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/domains/[domainId]/projects/[projectId]/sections', () => {
it('should list sections for a project', async () => {
const { GET } = await import('@/app/api/domains/[domainId]/projects/[projectId]/sections/route');
const { db } = require('@project-e/db');
const mockSections = [
{ id: '1', name: 'Section 1', projectId: 'project-1', kind: 'section', sortOrder: 0 },
{ id: '2', name: 'Milestone 1', projectId: 'project-1', kind: 'milestone', sortOrder: 1 },
];
db.select
.mockReturnValueOnce(chain([{ id: 'project-1' }])) // verify project
.mockReturnValueOnce(chain(mockSections)); // list sections
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1/sections');
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1', projectId: 'project-1' }) });
expect(response).toBeDefined();
});
});
describe('POST /api/domains/[domainId]/projects/[projectId]/sections', () => {
it('should create a section', async () => {
const { POST } = await import('@/app/api/domains/[domainId]/projects/[projectId]/sections/route');
const { db } = require('@project-e/db');
const mockProject = { id: 'project-1', name: 'Test Project' };
const mockSection = {
id: 'new-section-1',
name: 'Test Section',
projectId: 'project-1',
kind: 'section',
sortOrder: 0,
};
db.select
.mockReturnValueOnce(chain([mockProject])) // verify project
.mockReturnValueOnce(chain([{ max: -1 }])); // max sort order
db.insert.mockReturnValue(insertChain([mockSection]));
const request = new Request('http://localhost:3000/api/domains/domain-1/projects/project-1/sections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test Section' }),
});
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', projectId: 'project-1' }) });
expect(response).toBeDefined();
});
});
});
+221 -13
View File
@@ -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>
);
}
+243 -352
View File
@@ -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>
);
}
+149 -75
View File
@@ -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);
}
});
@@ -0,0 +1,126 @@
'use client';
import { useState, useEffect } from 'react';
interface Completion {
id: string;
date: string;
value: number;
mood: number | null;
notes: string | null;
}
interface HabitCalendarHeatmapProps {
habitId: string;
domainId: string;
}
export function HabitCalendarHeatmap({ habitId, domainId }: HabitCalendarHeatmapProps) {
const [completions, setCompletions] = useState<Completion[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchCompletions = async () => {
try {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 365);
const res = await fetch(
`/api/domains/${domainId}/habits/${habitId}/completions?from=${from.toISOString()}&to=${to.toISOString()}&limit=400`
);
const data = await res.json();
setCompletions(data.items || []);
} catch {
// silently fail
} finally {
setLoading(false);
}
};
fetchCompletions();
}, [habitId, domainId]);
if (loading) {
return <div className="py-4 text-center text-sm text-muted-foreground">Loading heatmap...</div>;
}
// Build a map of date -> completion
const completionMap = new Map<string, Completion>();
for (const c of completions) {
const dateKey = new Date(c.date).toISOString().split('T')[0];
completionMap.set(dateKey, c);
}
// Generate last 365 days
const today = new Date();
const days: { date: Date; dateStr: string; completion?: Completion }[] = [];
for (let i = 364; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split('T')[0];
days.push({ date: d, dateStr, completion: completionMap.get(dateStr) });
}
// Group by weeks (columns)
const weeks: typeof days[] = [];
let currentWeek: typeof days = [];
for (const day of days) {
currentWeek.push(day);
if (day.date.getDay() === 6) {
weeks.push(currentWeek);
currentWeek = [];
}
}
if (currentWeek.length > 0) weeks.push(currentWeek);
const getIntensity = (completion?: Completion): string => {
if (!completion) return 'bg-muted';
const v = completion.value || 1;
if (v >= 4) return 'bg-green-600';
if (v >= 3) return 'bg-green-500';
if (v >= 2) return 'bg-green-400';
return 'bg-green-300';
};
const getTooltip = (day: typeof days[0]): string => {
if (!day.completion) {
return day.date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) + ' — No entry';
}
const parts = [
day.date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }),
`Value: ${day.completion.value}`,
];
if (day.completion.mood) parts.push(`Mood: ${day.completion.mood}/5`);
if (day.completion.notes) parts.push(`Notes: ${day.completion.notes}`);
return parts.join(' | ');
};
return (
<div className="overflow-x-auto">
<div className="flex gap-1">
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col gap-1">
{week.map((day) => (
<div
key={day.dateStr}
className={`h-3 w-3 rounded-sm ${getIntensity(day.completion)}`}
title={getTooltip(day)}
/>
))}
</div>
))}
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>Less</span>
<div className="flex gap-0.5">
<div className="h-3 w-3 rounded-sm bg-muted" />
<div className="h-3 w-3 rounded-sm bg-green-300" />
<div className="h-3 w-3 rounded-sm bg-green-400" />
<div className="h-3 w-3 rounded-sm bg-green-500" />
<div className="h-3 w-3 rounded-sm bg-green-600" />
</div>
<span>More</span>
</div>
</div>
);
}
@@ -1,14 +1,15 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
@@ -16,111 +17,99 @@ import { Textarea } from '@/components/ui/textarea';
interface Habit {
id: string;
name: string;
unit: string | null;
moodTracking: boolean;
}
interface HabitCompletionDialogProps {
habit: Habit;
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: { mood?: number; value?: number; notes?: string }) => void;
habit: Habit;
onComplete: (value: number, mood?: number, notes?: string) => void;
}
const moods = [
{ value: 5, label: 'Great' },
{ value: 4, label: 'Good' },
{ value: 3, label: 'Okay' },
{ value: 2, label: 'Meh' },
{ value: 1, label: 'Bad' },
const moodEmojis = [
{ value: 1, emoji: '😞', label: 'Bad' },
{ value: 2, emoji: '😐', label: 'Okay' },
{ value: 3, emoji: '🙂', label: 'Good' },
{ value: 4, emoji: '😊', label: 'Great' },
{ value: 5, emoji: '🤩', label: 'Amazing' },
];
export function HabitCompletionDialog({
habit,
open,
onOpenChange,
onSubmit,
habit,
onComplete,
}: HabitCompletionDialogProps) {
const [mood, setMood] = useState<number | undefined>();
const [quantity, setQuantity] = useState<number | undefined>();
const [value, setValue] = useState('1');
const [mood, setMood] = useState<number | null>(null);
const [notes, setNotes] = useState('');
function handleSubmit() {
onSubmit({
mood,
value: quantity,
notes: notes || undefined,
});
setMood(undefined);
setQuantity(undefined);
setNotes('');
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Log {habit.name}</DialogTitle>
<DialogDescription>
How did it go? (optional you can skip and just log completion)
</DialogDescription>
<DialogTitle>Log &quot;{habit.name}&quot;</DialogTitle>
<DialogDescription>Record your progress for today.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Mood picker */}
<div className="space-y-2">
<Label>Mood</Label>
<div className="flex gap-2">
{moods.map((m) => (
<Button
key={m.value}
variant={mood === m.value ? 'default' : 'outline'}
size="sm"
onClick={() => setMood(m.value)}
className="flex-1"
>
{m.label}
</Button>
))}
<div className="space-y-4">
{habit.unit && (
<div className="space-y-2">
<Label htmlFor="completion-value">Value ({habit.unit})</Label>
<Input
id="completion-value"
type="number"
min={1}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
</div>
)}
{/* Quantity */}
<div className="space-y-2">
<Label htmlFor="quantity">Quantity (optional)</Label>
<Input
id="quantity"
type="number"
placeholder="e.g., 30"
value={quantity ?? ''}
onChange={(e) =>
setQuantity(e.target.value ? Number(e.target.value) : undefined)
}
/>
</div>
{habit.moodTracking && (
<div className="space-y-2">
<Label>Mood</Label>
<div className="flex gap-2">
{moodEmojis.map((m) => (
<button
key={m.value}
type="button"
onClick={() => setMood(mood === m.value ? null : m.value)}
className={`flex h-10 w-10 items-center justify-center rounded-lg text-lg transition-colors ${
mood === m.value
? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-accent'
}`}
title={m.label}
aria-label={`Mood: ${m.label}`}
>
{m.emoji}
</button>
))}
</div>
</div>
)}
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Notes (optional)</Label>
<Label htmlFor="completion-notes">Notes (optional)</Label>
<Textarea
id="notes"
placeholder="Any thoughts or reflections..."
id="completion-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
placeholder="How did it go?"
rows={2}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleSubmit} className="flex-1">
Log completion
</Button>
<Button
variant="outline"
onClick={() => onSubmit({})}
className="flex-1"
>
Skip
</Button>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" onClick={() => onComplete(parseInt(value) || 1, mood || undefined, notes || undefined)}>
Save
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,223 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
interface HabitCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
domainId: string;
onCreated: () => void;
}
export function HabitCreateDialog({
open,
onOpenChange,
domainId,
onCreated,
}: HabitCreateDialogProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [frequency, setFrequency] = useState<'daily' | 'weekly' | 'custom'>('daily');
const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard'>('medium');
const [goalPerPeriod, setGoalPerPeriod] = useState('1');
const [unit, setUnit] = useState('');
const [reminderTime, setReminderTime] = useState('');
const [moodTracking, setMoodTracking] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
setName('');
setDescription('');
setFrequency('daily');
setDifficulty('medium');
setGoalPerPeriod('1');
setUnit('');
setReminderTime('');
setMoodTracking(false);
setError('');
}
}, [open]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!domainId) {
setError('No domain selected');
return;
}
setSubmitting(true);
setError('');
const body: Record<string, unknown> = {
name,
frequency,
difficulty,
goalPerPeriod: parseInt(goalPerPeriod, 10) || 1,
moodTracking,
};
if (description) body.description = description;
if (unit) body.unit = unit;
if (reminderTime) body.reminderTime = reminderTime;
try {
const response = await fetch(`/api/domains/${domainId}/habits`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error?.message || 'Unable to create habit');
}
toast.success('Habit created');
onOpenChange(false);
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to create habit');
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>New Habit</DialogTitle>
<DialogDescription>Create a new habit to track daily or weekly.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="habit-name">Name *</Label>
<Input
id="habit-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Morning meditation"
autoFocus
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="habit-description">Description</Label>
<Textarea
id="habit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional details..."
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="habit-frequency">Frequency</Label>
<Select value={frequency} onValueChange={(v) => setFrequency(v as any)}>
<SelectTrigger id="habit-frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="habit-difficulty">Difficulty</Label>
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as any)}>
<SelectTrigger id="habit-difficulty">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="habit-goal">Goal per period</Label>
<Input
id="habit-goal"
type="number"
min={1}
value={goalPerPeriod}
onChange={(e) => setGoalPerPeriod(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="habit-unit">Unit (optional)</Label>
<Input
id="habit-unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder="e.g. minutes, pages"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="habit-reminder">Reminder time (optional)</Label>
<Input
id="habit-reminder"
type="time"
value={reminderTime}
onChange={(e) => setReminderTime(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Switch
id="habit-mood"
checked={moodTracking}
onCheckedChange={setMoodTracking}
/>
<Label htmlFor="habit-mood">Enable mood tracking</Label>
</div>
{error && <p className="text-sm text-destructive" role="alert">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={submitting || !name || !domainId}>
{submitting ? 'Creating...' : 'Create Habit'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,176 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
interface ProjectCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
domainId: string;
onCreated: () => void;
}
export function ProjectCreateDialog({
open,
onOpenChange,
domainId,
onCreated,
}: ProjectCreateDialogProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [status, setStatus] = useState<'active' | 'paused' | 'completed' | 'archived'>('active');
const [color, setColor] = useState('');
const [targetDate, setTargetDate] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
setName('');
setDescription('');
setStatus('active');
setColor('');
setTargetDate('');
setError('');
}
}, [open]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!domainId) {
setError('No domain selected');
return;
}
setSubmitting(true);
setError('');
const body: Record<string, unknown> = { name, status };
if (description) body.description = description;
if (color) body.color = color;
if (targetDate) body.targetDate = new Date(targetDate).toISOString();
try {
const response = await fetch(`/api/domains/${domainId}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error?.message || 'Unable to create project');
}
toast.success('Project created');
onOpenChange(false);
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to create project');
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>New Project</DialogTitle>
<DialogDescription>Create a new project to organize your work.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="project-name">Name *</Label>
<Input
id="project-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
autoFocus
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-description">Description</Label>
<Textarea
id="project-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description..."
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="project-status">Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as any)}>
<SelectTrigger id="project-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="project-color">Color</Label>
<Input
id="project-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="project-target-date">Target date</Label>
<Input
id="project-target-date"
type="date"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive" role="alert">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={submitting || !name || !domainId}>
{submitting ? 'Creating...' : 'Create Project'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,163 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
interface SectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string;
domainId: string;
onCreated: () => void;
}
export function SectionDialog({
open,
onOpenChange,
projectId,
domainId,
onCreated,
}: SectionDialogProps) {
const [name, setName] = useState('');
const [kind, setKind] = useState<'section' | 'milestone'>('section');
const [status, setStatus] = useState<'planned' | 'in_progress' | 'complete'>('planned');
const [targetDate, setTargetDate] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
setName('');
setKind('section');
setStatus('planned');
setTargetDate('');
setError('');
}
}, [open]);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!name) {
setError('Name is required');
return;
}
setSubmitting(true);
setError('');
const body: Record<string, unknown> = { name, kind, status };
if (targetDate) body.targetDate = new Date(targetDate).toISOString();
try {
const response = await fetch(`/api/domains/${domainId}/projects/${projectId}/sections`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error?.message || 'Unable to create section');
}
toast.success('Section created');
onOpenChange(false);
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to create section');
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle>New Section</DialogTitle>
<DialogDescription>Add a section or milestone to organize tasks.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="section-name">Name *</Label>
<Input
id="section-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Backend, Design, Launch"
autoFocus
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="section-kind">Kind</Label>
<Select value={kind} onValueChange={(v) => setKind(v as any)}>
<SelectTrigger id="section-kind">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="section">Section</SelectItem>
<SelectItem value="milestone">Milestone</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="section-status">Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as any)}>
<SelectTrigger id="section-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="planned">Planned</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="complete">Complete</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="section-target-date">Target date</Label>
<Input
id="section-target-date"
type="date"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive" role="alert">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={submitting || !name}>
{submitting ? 'Creating...' : 'Create Section'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+15
View File
@@ -73,6 +73,21 @@ export function useKeyboardShortcuts() {
document.dispatchEvent(new CustomEvent('open-create-task', { detail: { status: 'todo' } }));
e.preventDefault();
}
// c h — new habit
if (window.location.pathname.startsWith('/habits')) {
document.dispatchEvent(new CustomEvent('open-create-habit'));
e.preventDefault();
}
// c p — new project
if (window.location.pathname.startsWith('/projects')) {
document.dispatchEvent(new CustomEvent('open-create-project'));
e.preventDefault();
}
// c s — new section (on project detail page)
if (window.location.pathname.match(/^\/projects\/[^/]+$/)) {
document.dispatchEvent(new CustomEvent('open-create-section'));
e.preventDefault();
}
break;
}
case 'e': {
File diff suppressed because one or more lines are too long