Files
ProjectE/apps/web/app/(dashboard)/calendar/page.tsx
T
mbatchelder 8f55626e03 refactor: migrate to monorepo structure with Docker, PocketBase, and e2e tests
- Reorganize into apps/, packages/, docs/, e2e/, pocketbase/ directories
- Add Dockerfiles for web, worker, and PocketBase services
- Add docker-compose.yml for local orchestration
- Add turbo.json for monorepo task management
- Add Playwright e2e test infrastructure
- Add PocketBase backend with migrations
- Remove Vite/Next.js/ESLint/PostCSS config files
- Update package.json with workspace dependencies
- Add .env.example and .dockerignore
2026-07-16 06:19:58 -04:00

286 lines
9.9 KiB
TypeScript

'use client';
import { useEffect, useState, useMemo, Suspense } from 'react';
import { Filter } from 'lucide-react';
import dynamic from 'next/dynamic';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
// Lazy load react-big-calendar (~60KB + date-fns)
const BigCalendar = dynamic(
() => import('@/components/calendar/big-calendar-wrapper').then((m) => m.BigCalendarWrapper),
{
ssr: false,
loading: () => (
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
</div>
),
}
);
interface CalendarEvent {
id: string;
title: string;
start: Date;
end: Date;
type: 'task' | 'habit' | 'project' | 'milestone';
domain: string;
color: string;
}
export default function CalendarPage() {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(true);
const [showTasks, setShowTasks] = useState(true);
const [showHabits, setShowHabits] = useState(true);
const [showProjects, setShowProjects] = useState(true);
const [showMilestones, setShowMilestones] = useState(true);
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
useEffect(() => {
fetchEvents();
}, []);
async function fetchEvents() {
try {
// Fetch tasks with due dates
const tasksResponse = await fetch('/api/tasks?filter=due_date!%3D%22%22&perPage=500');
const tasksData = tasksResponse.ok ? await tasksResponse.json() : { items: [] };
// Fetch projects with target dates
const projectsResponse = await fetch('/api/projects?filter=target_date!%3D%22%22&perPage=500');
const projectsData = projectsResponse.ok ? await projectsResponse.json() : { items: [] };
// Fetch milestones with target dates
const milestonesResponse = await fetch('/api/milestones?filter=target_date!%3D%22%22&perPage=500');
const milestonesData = milestonesResponse.ok ? await milestonesResponse.json() : { items: [] };
const calendarEvents: CalendarEvent[] = [];
// Add tasks
if (tasksData.items) {
for (const task of tasksData.items) {
if (task.due_date) {
const date = new Date(task.due_date);
calendarEvents.push({
id: `task-${task.id}`,
title: task.title,
start: date,
end: date,
type: 'task',
domain: task.domain ?? 'personal',
color: '#3b82f6', // blue
});
}
}
}
// Add projects
if (projectsData.items) {
for (const project of projectsData.items) {
if (project.target_date) {
const date = new Date(project.target_date);
calendarEvents.push({
id: `project-${project.id}`,
title: `📁 ${project.name}`,
start: date,
end: date,
type: 'project',
domain: project.domain ?? 'personal',
color: '#8b5cf6', // purple
});
}
}
}
// Add milestones
if (milestonesData.items) {
for (const milestone of milestonesData.items) {
if (milestone.target_date) {
const date = new Date(milestone.target_date);
calendarEvents.push({
id: `milestone-${milestone.id}`,
title: `🎯 ${milestone.name}`,
start: date,
end: date,
type: 'milestone',
domain: milestone.domain ?? 'work',
color: '#f59e0b', // amber
});
}
}
}
setEvents(calendarEvents);
} catch (error) {
console.error('Failed to fetch calendar events:', error);
} finally {
setLoading(false);
}
}
const filteredEvents = useMemo(() => {
return events.filter((event) => {
// Filter by type
if (event.type === 'task' && !showTasks) return false;
if (event.type === 'habit' && !showHabits) return false;
if (event.type === 'project' && !showProjects) return false;
if (event.type === 'milestone' && !showMilestones) return false;
// Filter by domain
if (selectedDomains.length > 0 && !selectedDomains.includes(event.domain)) {
return false;
}
return true;
});
}, [events, showTasks, showHabits, showProjects, showMilestones, selectedDomains]);
function toggleDomain(domain: string) {
setSelectedDomains((prev) =>
prev.includes(domain) ? prev.filter((d) => d !== domain) : [...prev, domain]
);
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<p className="text-muted-foreground">Loading calendar...</p>
</div>
);
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Calendar</h1>
<p className="mt-1 text-muted-foreground">Your commitments, in time.</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr]">
{/* Filters sidebar */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Filter className="h-4 w-4" aria-hidden="true" />
Filters
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Entity types */}
<div className="space-y-3">
<h2 className="text-sm font-semibold">Show</h2>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="tasks"
checked={showTasks}
onCheckedChange={(checked) => setShowTasks(checked === true)}
/>
<Label htmlFor="tasks" className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#3b82f6' }} />
Tasks
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="habits"
checked={showHabits}
onCheckedChange={(checked) => setShowHabits(checked === true)}
/>
<Label htmlFor="habits" className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#10b981' }} />
Habits
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="projects"
checked={showProjects}
onCheckedChange={(checked) => setShowProjects(checked === true)}
/>
<Label htmlFor="projects" className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#8b5cf6' }} />
Projects
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="milestones"
checked={showMilestones}
onCheckedChange={(checked) => setShowMilestones(checked === true)}
/>
<Label htmlFor="milestones" className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#f59e0b' }} />
Milestones
</Label>
</div>
</div>
</div>
{/* Domains */}
<div className="space-y-3">
<h2 className="text-sm font-semibold">Domains</h2>
<div className="space-y-2">
{['personal', 'work', 'ots'].map((domain) => (
<div key={domain} className="flex items-center space-x-2">
<Checkbox
id={domain}
checked={selectedDomains.includes(domain)}
onCheckedChange={() => toggleDomain(domain)}
/>
<Label htmlFor={domain}>
<Badge variant="outline">{domain}</Badge>
</Label>
</div>
))}
</div>
{selectedDomains.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedDomains([])}
className="text-xs"
>
Clear filters
</Button>
)}
</div>
{/* Legend */}
<div className="space-y-2 border-t pt-4">
<h2 className="text-sm font-semibold">Legend</h2>
<div className="space-y-1 text-xs text-muted-foreground">
<p> Tasks show on due date</p>
<p> Projects show on deadline</p>
<p> Milestones show on due date</p>
</div>
</div>
</CardContent>
</Card>
{/* Calendar */}
<Card>
<CardContent className="p-4">
<Suspense
fallback={
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
</div>
}
>
<BigCalendar events={filteredEvents} />
</Suspense>
</CardContent>
</Card>
</div>
</div>
);
}