feat: migrate schema to DATE type, add test infrastructure

- Migrate due_date/next_occurrence columns from TIMESTAMPTZ to DATE
- Update serializeRow() to distinguish DATE vs TIMESTAMPTZ serialization
- Simplify frontend date parsing (no more timezone workarounds)
- Add Vitest + Testing Library test infrastructure
- Add initial date parsing/formatting unit tests
- Update package.json with dev dependencies (vitest, testing-library, jsdom)
This commit is contained in:
Victor
2026-05-03 03:16:54 +00:00
commit 6daa8f7f59
43 changed files with 9786 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix',
});
function serializeRow(row: Record<string, any>): Record<string, any> {
const toCamel = (str: string) => str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(row)) {
const camelKey = toCamel(key);
if (value instanceof Date && !isNaN(value.getTime())) {
if (value.getUTCHours() === 0 && value.getUTCMinutes() === 0 && value.getUTCSeconds() === 0 && value.getUTCMilliseconds() === 0) {
const year = value.getUTCFullYear();
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
const day = String(value.getUTCDate()).padStart(2, '0');
result[camelKey] = `${year}-${month}-${day}`;
} else {
result[camelKey] = value.toISOString();
}
} else {
result[camelKey] = value;
}
}
return result;
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const projectId = searchParams.get('project');
try {
let whereClause = 'parent_task_id IS NULL';
let params: any[] = [];
let paramIndex = 1;
if (projectId) {
whereClause += ` AND project_id = $${paramIndex++}`;
params.push(projectId);
}
const result = await pool.query(
`SELECT t.*, p.color as project_color, p.name as project_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
WHERE ${whereClause}
ORDER BY t.sort_order`,
params
);
const rows = result.rows.map(serializeRow);
const grouped = {
todo: rows.filter((t: any) => t.status === 'todo'),
in_progress: rows.filter((t: any) => t.status === 'in_progress'),
done: rows.filter((t: any) => t.status === 'done'),
};
return NextResponse.json(grouped);
} catch (error) {
console.error('Error fetching kanban data:', error);
return NextResponse.json({ error: 'Failed to fetch kanban data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix',
});
function serializeRow(row: Record<string, any>): Record<string, any> {
const toCamel = (str: string) => str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(row)) {
const camelKey = toCamel(key);
if (value instanceof Date) {
result[camelKey] = value.toISOString();
} else {
result[camelKey] = value;
}
}
return result;
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const result = await pool.query('SELECT * FROM projects WHERE id = $1', [id]);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
return NextResponse.json(serializeRow(result.rows[0]));
} catch (error) {
console.error('Error fetching project:', error);
return NextResponse.json({ error: 'Failed to fetch project' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const { name, description, color, sortOrder } = body;
if (!name?.trim()) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
try {
const result = await pool.query(
`UPDATE projects SET name = $1, description = $2, color = $3, sort_order = $4 WHERE id = $5 RETURNING *`,
[name.trim(), description?.trim() || '', color, sortOrder, id]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
}
return NextResponse.json(serializeRow(result.rows[0]));
} catch (error) {
console.error('Error updating project:', error);
return NextResponse.json({ error: 'Failed to update project' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
// Delete all tasks in the project first
await pool.query('DELETE FROM tasks WHERE project_id = $1', [id]);
// Then delete the project
await pool.query('DELETE FROM projects WHERE id = $1', [id]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting project:', error);
return NextResponse.json({ error: 'Failed to delete project' }, { status: 500 });
}
}

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix',
});
function serializeRow(row: Record<string, any>): Record<string, any> {
const toCamel = (str: string) => str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(row)) {
const camelKey = toCamel(key);
if (value instanceof Date && !isNaN(value.getTime())) {
if (value.getUTCHours() === 0 && value.getUTCMinutes() === 0 && value.getUTCSeconds() === 0 && value.getUTCMilliseconds() === 0) {
const year = value.getUTCFullYear();
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
const day = String(value.getUTCDate()).padStart(2, '0');
result[camelKey] = `${year}-${month}-${day}`;
} else {
result[camelKey] = value.toISOString();
}
} else {
result[camelKey] = value;
}
}
return result;
}
export async function GET(request: NextRequest) {
try {
const result = await pool.query('SELECT * FROM projects ORDER BY sort_order');
return NextResponse.json(result.rows.map(serializeRow));
} catch (error) {
console.error('Error fetching projects:', error);
return NextResponse.json({ error: 'Failed to fetch projects' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const { name, description = '', color = '#3b82f6' } = body;
if (!name?.trim()) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
try {
const maxSort = await pool.query('SELECT COALESCE(MAX(sort_order), -1) as max FROM projects');
const result = await pool.query(
`INSERT INTO projects (name, description, color, sort_order) VALUES ($1, $2, $3, $4) RETURNING *`,
[name.trim(), description, color, (maxSort.rows[0].max ?? -1) + 1]
);
return NextResponse.json(serializeRow(result.rows[0]), { status: 201 });
} catch (error) {
console.error('Error creating project:', error);
return NextResponse.json({ error: 'Failed to create project' }, { status: 500 });
}
}

View File

@@ -0,0 +1,214 @@
import { NextRequest, NextResponse } from 'next/server';
import { Pool } from 'pg';
import { addDays, addWeeks, addMonths, addYears } from 'date-fns';
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix',
});
function toCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
}
function serializeRow(row: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(row)) {
const camelKey = toCamel(key);
if (value instanceof Date && !isNaN(value.getTime())) {
if (value.getUTCHours() === 0 && value.getUTCMinutes() === 0 && value.getUTCSeconds() === 0 && value.getUTCMilliseconds() === 0) {
const year = value.getUTCFullYear();
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
const day = String(value.getUTCDate()).padStart(2, '0');
result[camelKey] = `${year}-${month}-${day}`;
} else {
result[camelKey] = value.toISOString();
}
} else {
result[camelKey] = value;
}
}
return result;
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
const taskResult = await pool.query(
'SELECT * FROM tasks WHERE id = $1',
[id]
);
if (taskResult.rows.length === 0) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
const subtaskResult = await pool.query(
'SELECT * FROM tasks WHERE parent_task_id = $1 ORDER BY sort_order',
[id]
);
return NextResponse.json({
...serializeRow(taskResult.rows[0]),
subtasks: subtaskResult.rows.map(serializeRow),
});
} catch (error) {
console.error('Error fetching task:', error);
return NextResponse.json({ error: 'Failed to fetch task' }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const {
title,
description,
projectId,
priority,
dueDate,
status,
recurrenceRule,
recurrenceInterval,
nextOccurrence,
} = body;
if (!title?.trim()) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
}
try {
const result = await pool.query(
`UPDATE tasks SET title = $1, description = $2, project_id = $3, priority = $4, due_date = $5, status = $6, recurrence_rule = $7, recurrence_interval = $8, next_occurrence = $9, updated_at = $10
WHERE id = $11 RETURNING *`,
[
title.trim(),
description?.trim() || '',
projectId,
priority,
dueDate || null,
status,
recurrenceRule,
recurrenceInterval,
nextOccurrence || null,
new Date(),
id,
]
);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
return NextResponse.json(serializeRow(result.rows[0]));
} catch (error) {
console.error('Error updating task:', error);
return NextResponse.json({ error: 'Failed to update task' }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
await pool.query('DELETE FROM tasks WHERE parent_task_id = $1', [id]);
await pool.query('DELETE FROM tasks WHERE id = $1', [id]);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting task:', error);
return NextResponse.json({ error: 'Failed to delete task' }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { action } = await request.json();
try {
const taskResult = await pool.query('SELECT * FROM tasks WHERE id = $1', [id]);
if (taskResult.rows.length === 0) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 });
}
const task = taskResult.rows[0];
if (action === 'toggle-done') {
// If completing a recurring task, create the next occurrence
if (!task.completed && task.recurrence_rule !== 'none' && task.due_date) {
const currentDate = new Date(task.due_date);
const nextDate = getNextOccurrence(currentDate, task.recurrence_rule, task.recurrence_interval);
// Convert to "YYYY-MM-DD" string for DATE column
const nextDateStr = nextDate.toISOString().split('T')[0];
await pool.query(
`INSERT INTO tasks (title, description, project_id, priority, due_date, status, completed, parent_task_id, recurrence_rule, recurrence_interval, sort_order, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, 'todo', FALSE, $6, $7, $8, $9, $10, $11)`,
[
task.title,
task.description,
task.project_id,
task.priority,
nextDateStr,
task.id,
task.recurrence_rule,
task.recurrence_interval,
(task.sort_order || 0) + 1,
new Date(),
new Date(),
]
);
}
await pool.query(
`UPDATE tasks SET completed = NOT completed, status = CASE WHEN NOT completed THEN 'done' ELSE status END, updated_at = $1 WHERE id = $2`,
[new Date(), id]
);
const updatedResult = await pool.query('SELECT * FROM tasks WHERE id = $1', [id]);
return NextResponse.json(serializeRow(updatedResult.rows[0]));
}
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
} catch (error) {
console.error('Error toggling task:', error);
return NextResponse.json({ error: 'Failed to toggle task' }, { status: 500 });
}
}
function getNextOccurrence(fromDate: Date, rule: string, interval: number): Date {
switch (rule) {
case 'daily':
return addDays(fromDate, interval);
case 'weekly':
return addWeeks(fromDate, interval);
case 'biweekly':
return addWeeks(fromDate, interval * 2);
case 'monthly':
return addMonths(fromDate, interval);
case 'yearly':
return addYears(fromDate, interval);
default:
return fromDate;
}
}

144
src/app/api/tasks/route.ts Normal file
View File

@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from 'next/server';
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix',
});
function toCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
}
function serializeRow(row: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(row)) {
const camelKey = toCamel(key);
if (value instanceof Date && !isNaN(value.getTime())) {
// pg driver converts both DATE and TIMESTAMPTZ to Date objects
// DATE columns are at midnight UTC (00:00:00.000)
// TIMESTAMPTZ columns have actual time components
if (value.getUTCHours() === 0 && value.getUTCMinutes() === 0 && value.getUTCSeconds() === 0 && value.getUTCMilliseconds() === 0) {
// DATE column - return "YYYY-MM-DD"
const year = value.getUTCFullYear();
const month = String(value.getUTCMonth() + 1).padStart(2, '0');
const day = String(value.getUTCDate()).padStart(2, '0');
result[camelKey] = `${year}-${month}-${day}`;
} else {
// TIMESTAMPTZ column - return ISO string
result[camelKey] = value.toISOString();
}
} else {
result[camelKey] = value;
}
}
return result;
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const project = searchParams.get('project');
const search = searchParams.get('search');
const status = searchParams.get('status');
const priority = searchParams.get('priority');
const dueBefore = searchParams.get('due_before');
const dueAfter = searchParams.get('due_after');
const completed = searchParams.get('completed');
const sortBy = searchParams.get('sort_by') || 'due_date';
try {
// Build dynamic query
let query = 'SELECT * FROM tasks WHERE 1=1';
const params: any[] = [];
let paramIndex = 1;
if (project) {
query += ` AND project_id = $${paramIndex}`;
params.push(project);
paramIndex++;
}
if (search) {
query += ` AND title ILIKE $${paramIndex}`;
params.push(`%${search}%`);
paramIndex++;
}
if (status) {
query += ` AND status = $${paramIndex}`;
params.push(status);
paramIndex++;
}
if (priority) {
query += ` AND priority = $${paramIndex}`;
params.push(priority);
paramIndex++;
}
if (dueBefore) {
query += ` AND due_date <= $${paramIndex}`;
params.push(dueBefore);
paramIndex++;
}
if (dueAfter) {
query += ` AND due_date >= $${paramIndex}`;
params.push(dueAfter);
paramIndex++;
}
if (completed !== null) {
query += ` AND completed = $${paramIndex}`;
params.push(completed === 'true');
paramIndex++;
}
// Sort order
const sortColumn = sortBy === 'priority' ? 'priority' : 'due_date';
const sortOrder = sortBy === 'priority' ? 'CASE priority WHEN \'urgent\' THEN 1 WHEN \'high\' THEN 2 WHEN \'medium\' THEN 3 WHEN \'low\' THEN 4 END' : 'due_date';
query += ` ORDER BY ${sortColumn === 'due_date' ? 'due_date NULLS LAST' : sortOrder}`;
query += ', title';
const result = await pool.query(query, params);
return NextResponse.json(result.rows.map(serializeRow));
} catch (error) {
console.error('Error fetching tasks:', error);
return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const { title, description, projectId, priority, dueDate, status, recurrenceRule, recurrenceInterval, parentTaskId } = body;
if (!title?.trim()) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
}
try {
const result = await pool.query(
`INSERT INTO tasks (title, description, project_id, priority, due_date, status, recurrence_rule, recurrence_interval, parent_task_id, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM tasks))
RETURNING *`,
[
title.trim(),
description?.trim() || '',
projectId || null,
priority || 'medium',
dueDate || null,
status || 'todo',
recurrenceRule || 'none',
recurrenceInterval || 1,
parentTaskId || null,
]
);
return NextResponse.json(serializeRow(result.rows[0]));
} catch (error) {
console.error('Error creating task:', error);
return NextResponse.json({ error: 'Failed to create task' }, { status: 500 });
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

81
src/app/globals.css Normal file
View File

@@ -0,0 +1,81 @@
@import "tailwindcss";
:root {
--sidebar-bg: #0f172a;
--sidebar-hover: #1e293b;
--sidebar-active: #334155;
--content-bg: #f8fafc;
--card-bg: #ffffff;
--text-primary: #0f172a;
--text-secondary: #64748b;
--border-color: #e2e8f0;
--accent: #3b82f6;
--accent-hover: #2563eb;
--priority-urgent: #ef4444;
--priority-high: #f97316;
--priority-medium: #eab308;
--priority-low: #22c55e;
}
@theme inline {
--color-sidebar: var(--sidebar-bg);
--color-sidebar-hover: var(--sidebar-hover);
--color-sidebar-active: var(--sidebar-active);
--color-content: var(--content-bg);
--color-card: var(--card-bg);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-border: var(--border-color);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--content-bg);
color: var(--text-primary);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Animations */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.animate-slide-in {
animation: slideIn 0.2s ease-out;
}
.animate-fade-in {
animation: fadeIn 0.15s ease-out;
}

22
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import "./globals.css";
import AppProvider from "@/components/AppProvider";
export const metadata: Metadata = {
title: "VixTix - Personal Productivity",
description: "Organize your life, one task at a time.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased h-screen overflow-hidden">
<AppProvider>{children}</AppProvider>
</body>
</html>
);
}

48
src/app/page.tsx Normal file
View File

@@ -0,0 +1,48 @@
'use client';
import { useApp } from '@/components/AppProvider';
import Sidebar from '@/components/Sidebar';
import Header from '@/components/Header';
import FilterPanel from '@/components/FilterPanel';
import ListView from '@/components/ListView';
import KanbanBoard from '@/components/KanbanBoard';
import CalendarView from '@/components/CalendarView';
import NewTaskModal from '@/components/NewTaskModal';
import EditTaskModal from '@/components/EditTaskModal';
import { useEffect } from 'react';
export default function Home() {
const { view, refreshTasks, refreshProjects } = useApp();
useEffect(() => {
refreshTasks();
refreshProjects();
}, [refreshTasks, refreshProjects]);
return (
<div className="flex h-screen">
{/* Sidebar */}
<Sidebar />
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<Header />
{/* Filter panel */}
<FilterPanel />
{/* Content area */}
<div className="flex-1 overflow-hidden">
{view === 'list' && <ListView />}
{view === 'kanban' && <KanbanBoard />}
{view === 'calendar' && <CalendarView />}
</div>
</div>
{/* Modals */}
<NewTaskModal />
<EditTaskModal />
</div>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import { createContext, useContext, useState, useCallback, type ReactNode, type Dispatch, type SetStateAction } from 'react';
export type ViewType = 'list' | 'kanban' | 'calendar';
export interface Task {
id: string;
projectId: string | null;
title: string;
description: string;
completed: boolean;
priority: 'low' | 'medium' | 'high' | 'urgent';
dueDate: string | null;
status: 'todo' | 'in_progress' | 'done';
parentTaskId: string | null;
recurrenceRule: 'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly';
recurrenceInterval: number;
nextOccurrence: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface Project {
id: string;
name: string;
description: string;
color: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface AppState {
view: ViewType;
selectedProject: string | null;
selectedTask: Task | null;
searchQuery: string;
sidebarOpen: boolean;
showNewTaskModal: boolean;
showEditTaskModal: boolean;
showNewProjectModal: boolean;
showRecurrenceModal: boolean;
editingTask: Task | null;
filterStatus: string;
filterPriority: string;
filterDueBefore: string;
filterDueAfter: string;
filterCompleted: string;
tasks: Task[];
projects: Project[];
setView: (view: ViewType) => void;
setSelectedProject: (project: string | null) => void;
setSelectedTask: (task: Task | null) => void;
setSearchQuery: (query: string) => void;
setSidebarOpen: (open: boolean) => void;
setShowNewTaskModal: (show: boolean) => void;
setShowEditTaskModal: (show: boolean) => void;
setShowNewProjectModal: (show: boolean) => void;
setShowRecurrenceModal: (show: boolean) => void;
setEditingTask: (task: Task | null) => void;
setFilterStatus: (status: string) => void;
setFilterPriority: (priority: string) => void;
setFilterDueBefore: (date: string) => void;
setFilterDueAfter: (date: string) => void;
setFilterCompleted: (completed: string) => void;
setTasks: Dispatch<SetStateAction<Task[]>>;
setProjects: Dispatch<SetStateAction<Project[]>>;
refreshTasks: () => Promise<void>;
refreshProjects: () => Promise<void>;
}
const AppContext = createContext<AppState | null>(null);
export function useApp() {
const context = useContext(AppContext);
if (!context) throw new Error('useApp must be used within AppProvider');
return context;
}
export default function AppProvider({ children }: { children: ReactNode }) {
const [view, setView] = useState<ViewType>('list');
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(true);
const [showNewTaskModal, setShowNewTaskModal] = useState(false);
const [showEditTaskModal, setShowEditTaskModal] = useState(false);
const [showNewProjectModal, setShowNewProjectModal] = useState(false);
const [showRecurrenceModal, setShowRecurrenceModal] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [filterStatus, setFilterStatus] = useState('');
const [filterPriority, setFilterPriority] = useState('');
const [filterDueBefore, setFilterDueBefore] = useState('');
const [filterDueAfter, setFilterDueAfter] = useState('');
const [filterCompleted, setFilterCompleted] = useState('');
const [tasks, setTasks] = useState<Task[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const refreshTasks = useCallback(async () => {
try {
const params = new URLSearchParams();
if (selectedProject) params.set('project', selectedProject);
if (searchQuery) params.set('search', searchQuery);
if (filterStatus) params.set('status', filterStatus);
if (filterPriority) params.set('priority', filterPriority);
if (filterDueBefore) params.set('due_before', filterDueBefore);
if (filterDueAfter) params.set('due_after', filterDueAfter);
if (filterCompleted) params.set('completed', filterCompleted);
const res = await fetch(`/api/tasks?${params}`);
const data = await res.json();
setTasks(data);
} catch (error) {
console.error('Failed to refresh tasks:', error);
}
}, [selectedProject, searchQuery, filterStatus, filterPriority, filterDueBefore, filterDueAfter, filterCompleted]);
const refreshProjects = useCallback(async () => {
try {
const res = await fetch('/api/projects');
const data = await res.json();
setProjects(data);
} catch (error) {
console.error('Failed to refresh projects:', error);
}
}, []);
const value: AppState = {
view, selectedProject, selectedTask, searchQuery, sidebarOpen,
showNewTaskModal, showEditTaskModal, showNewProjectModal, showRecurrenceModal,
editingTask, filterStatus, filterPriority, filterDueBefore, filterDueAfter, filterCompleted,
tasks, projects,
setView, setSelectedProject, setSelectedTask, setSearchQuery, setSidebarOpen,
setShowNewTaskModal, setShowEditTaskModal, setShowNewProjectModal, setShowRecurrenceModal,
setEditingTask, setFilterStatus, setFilterPriority, setFilterDueBefore, setFilterDueAfter,
setFilterCompleted, setTasks, setProjects,
refreshTasks, refreshProjects,
};
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

View File

@@ -0,0 +1,160 @@
'use client';
import { useApp, type Task } from './AppProvider';
import { useState, useMemo } from 'react';
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek, addMonths, subMonths,
format, isSameDay, isToday, isPast, isFuture,
eachDayOfInterval,
} from 'date-fns';
export default function CalendarView() {
const { tasks, projects, setSelectedTask, setShowEditTaskModal, setEditingTask } = useApp();
const [currentDate, setCurrentDate] = useState(new Date());
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const calendarStart = startOfWeek(monthStart);
const calendarEnd = endOfWeek(monthEnd);
const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd });
const daysInMonth = days.filter(d => d.getMonth() === month);
// Group tasks by date
const tasksByDate = useMemo(() => {
const map = new Map<string, Task[]>();
tasks.forEach(task => {
if (!task.dueDate) return;
// DATE type returns "YYYY-MM-DD" directly
if (!map.has(task.dueDate)) map.set(task.dueDate, []);
map.get(task.dueDate)!.push(task);
});
return map;
}, [tasks]);
const priorityColors = {
urgent: 'bg-[var(--priority-urgent)]',
high: 'bg-[var(--priority-high)]',
medium: 'bg-[var(--priority-medium)]',
low: 'bg-[var(--priority-low)]',
};
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const monthName = format(currentDate, 'MMMM yyyy');
const handleDayClick = (date: Date) => {
// Could open a "new task" modal pre-filled with this date
console.log('Day clicked:', format(date, 'yyyy-MM-dd'));
};
const handleTaskClick = (task: Task) => {
setSelectedTask(task);
setEditingTask(task);
setShowEditTaskModal(true);
};
return (
<div className="flex-1 overflow-y-auto p-4">
<div className="max-w-4xl mx-auto">
{/* Month navigation */}
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
className="p-2 hover:bg-content rounded-lg transition-colors"
>
</button>
<h2 className="text-xl font-bold">{monthName}</h2>
<button
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
className="p-2 hover:bg-content rounded-lg transition-colors"
>
</button>
</div>
{/* Calendar grid */}
<div className="bg-card border border-border rounded-xl overflow-hidden">
{/* Week day headers */}
<div className="grid grid-cols-7 border-b border-border">
{weekDays.map(day => (
<div key={day} className="p-2 text-center text-xs font-semibold text-text-secondary bg-content">
{day}
</div>
))}
</div>
{/* Days */}
<div className="grid grid-cols-7">
{days.map((day, index) => {
const dateStr = format(day, 'yyyy-MM-dd');
const isCurrentMonth = day.getMonth() === month;
const isTodayDate = isToday(day);
const dayTasks = tasksByDate.get(dateStr) || [];
const incompleteTasks = dayTasks.filter(t => !t.completed);
return (
<div
key={dateStr}
onClick={() => handleDayClick(day)}
className={`min-h-[80px] p-1.5 border-b border-r border-border cursor-pointer hover:bg-content/50 transition-colors ${
!isCurrentMonth ? 'opacity-40' : ''
}`}
>
{/* Date number */}
<div className="flex items-center justify-between mb-1">
<span className={`text-xs font-medium ${
isTodayDate
? 'bg-accent text-white w-6 h-6 rounded-full flex items-center justify-center'
: isPast(day) && !isTodayDate
? 'text-text-secondary'
: 'text-text-primary'
}`}>
{format(day, 'd')}
</span>
{incompleteTasks.length > 0 && (
<span className="text-xs text-accent font-medium">{incompleteTasks.length}</span>
)}
</div>
{/* Task indicators */}
<div className="space-y-0.5">
{dayTasks.slice(0, 3).map(task => (
<button
key={task.id}
onClick={(e) => { e.stopPropagation(); handleTaskClick(task); }}
className={`w-full text-left text-xs px-1 py-0.5 rounded truncate ${priorityColors[task.priority]} bg-opacity-20 text-text-primary hover:bg-opacity-30 transition-colors`}
title={task.title}
>
{task.title}
</button>
))}
{dayTasks.length > 3 && (
<span className="text-xs text-text-secondary pl-1">
+{dayTasks.length - 3} more
</span>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Legend */}
<div className="mt-4 flex items-center gap-4 text-xs text-text-secondary">
<span className="font-medium">Priority:</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[var(--priority-urgent)]" /> Urgent</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[var(--priority-high)]" /> High</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[var(--priority-medium)]" /> Medium</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[var(--priority-low)]" /> Low</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,340 @@
'use client';
import { useApp, type Task } from './AppProvider';
import { useState, useEffect, useRef } from 'react';
import { format } from 'date-fns';
export default function EditTaskModal() {
const {
showEditTaskModal, setShowEditTaskModal,
selectedTask, editingTask, setEditingTask,
projects, refreshTasks,
} = useApp();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [projectId, setProjectId] = useState('');
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium');
const [dueDate, setDueDate] = useState('');
const [status, setStatus] = useState<'todo' | 'in_progress' | 'done'>('todo');
const [recurrenceRule, setRecurrenceRule] = useState<'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly'>('none');
const [recurrenceInterval, setRecurrenceInterval] = useState(1);
const [subtasks, setSubtasks] = useState<Task[]>([]);
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
useEffect(() => {
if (editingTask) {
setTitle(editingTask.title);
setDescription(editingTask.description);
setProjectId(editingTask.projectId || '');
setPriority(editingTask.priority);
setDueDate(editingTask.dueDate || '');
setStatus(editingTask.status);
setRecurrenceRule(editingTask.recurrenceRule);
setRecurrenceInterval(editingTask.recurrenceInterval);
}
}, [editingTask]);
// Fetch subtasks when modal opens
useEffect(() => {
if (showEditTaskModal && editingTask) {
fetch(`/api/tasks/${editingTask.id}`)
.then(res => res.json())
.then(data => {
setSubtasks(data.subtasks || []);
})
.catch(console.error);
}
}, [showEditTaskModal, editingTask]);
const handleSave = async () => {
if (!editingTask || !title.trim()) return;
try {
await fetch(`/api/tasks/${editingTask.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title.trim(),
description: description.trim(),
projectId: projectId || null,
priority,
dueDate: dueDate || null,
status,
recurrenceRule,
recurrenceInterval,
}),
});
setShowEditTaskModal(false);
refreshTasks();
} catch (error) {
console.error('Failed to update task:', error);
}
};
const handleDelete = async () => {
if (!editingTask) return;
if (!confirm('Delete this task and all its subtasks?')) return;
try {
await fetch(`/api/tasks/${editingTask.id}`, { method: 'DELETE' });
setShowEditTaskModal(false);
refreshTasks();
} catch (error) {
console.error('Failed to delete task:', error);
}
};
const handleToggleSubtask = async (subtask: Task) => {
try {
await fetch(`/api/tasks/${subtask.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'toggle-done' }),
});
// Refresh subtasks
const res = await fetch(`/api/tasks/${editingTask?.id}`);
const data = await res.json();
setSubtasks(data.subtasks || []);
refreshTasks();
} catch (error) {
console.error('Failed to toggle subtask:', error);
}
};
const handleAddSubtask = async () => {
if (!newSubtaskTitle.trim() || !editingTask) return;
try {
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newSubtaskTitle.trim(),
projectId: editingTask.projectId,
priority: editingTask.priority,
parentTaskId: editingTask.id,
}),
});
setNewSubtaskTitle('');
// Refresh subtasks
const res = await fetch(`/api/tasks/${editingTask.id}`);
const data = await res.json();
setSubtasks(data.subtasks || []);
refreshTasks();
} catch (error) {
console.error('Failed to add subtask:', error);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setShowEditTaskModal(false);
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSave();
}
};
if (!showEditTaskModal || !editingTask) return null;
const priorityConfig = {
urgent: { label: 'Urgent', icon: '🔴', bg: 'bg-[var(--priority-urgent)]/10', text: 'text-[var(--priority-urgent)]' },
high: { label: 'High', icon: '🟠', bg: 'bg-[var(--priority-high)]/10', text: 'text-[var(--priority-high)]' },
medium: { label: 'Medium', icon: '🟡', bg: 'bg-[var(--priority-medium)]/10', text: 'text-[var(--priority-medium)]' },
low: { label: 'Low', icon: '🟢', bg: 'bg-[var(--priority-low)]/10', text: 'text-[var(--priority-low)]' },
};
const prio = priorityConfig[priority];
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 animate-fade-in"
onClick={(e) => { if (e.target === e.currentTarget) setShowEditTaskModal(false); }}
>
<div
className="bg-card rounded-xl w-full max-w-lg mx-4 shadow-xl animate-slide-in max-h-[90vh] overflow-y-auto"
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border sticky top-0 bg-card z-10">
<h2 className="text-lg font-bold">Edit Task</h2>
<button
onClick={() => setShowEditTaskModal(false)}
className="text-text-secondary hover:text-text-primary transition-colors text-xl"
>
</button>
</div>
{/* Body */}
<div className="p-4 space-y-3">
{/* Title */}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-content border border-border rounded-lg px-3 py-2.5 text-sm font-medium focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
{/* Description */}
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description"
rows={3}
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors resize-none"
/>
{/* Grid: Project, Priority, Status, Due Date */}
<div className="grid grid-cols-2 gap-3">
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
>
<option value="">No Project</option>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<select
value={status}
onChange={(e) => setStatus(e.target.value as any)}
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
>
<option value="todo">📋 To Do</option>
<option value="in_progress">🔄 In Progress</option>
<option value="done"> Done</option>
</select>
<select
value={priority}
onChange={(e) => setPriority(e.target.value as any)}
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
>
<option value="low">🟢 Low</option>
<option value="medium">🟡 Medium</option>
<option value="high">🟠 High</option>
<option value="urgent">🔴 Urgent</option>
</select>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
</div>
{/* Recurrence */}
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Recurrence</label>
<div className="flex items-center gap-2">
<select
value={recurrenceRule}
onChange={(e) => setRecurrenceRule(e.target.value as any)}
className="flex-1 bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
>
<option value="none">Not recurring</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Bi-weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
{recurrenceRule !== 'none' && (
<input
type="number"
min={1}
max={365}
value={recurrenceInterval}
onChange={(e) => setRecurrenceInterval(Number(e.target.value))}
className="w-16 bg-content border border-border rounded-lg px-3 py-2 text-sm text-center focus:outline-none focus:border-accent"
/>
)}
</div>
{recurrenceRule !== 'none' && (
<p className="text-xs text-text-secondary">
Repeats every {recurrenceInterval} {recurrenceRule === 'daily' ? 'day' : recurrenceRule === 'weekly' ? 'week' : recurrenceRule === 'biweekly' ? 'weeks' : recurrenceRule === 'monthly' ? 'month' : recurrenceRule === 'yearly' ? 'year' : ''}
{recurrenceInterval > 1 ? 's' : ''}
</p>
)}
</div>
{/* Subtasks */}
<div>
<label className="text-sm font-medium text-text-secondary">Subtasks ({subtasks.filter(s => !s.completed).length}/{subtasks.length})</label>
<div className="mt-2 space-y-1">
{subtasks.map(subtask => (
<div
key={subtask.id}
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-colors ${
subtask.completed ? 'opacity-60' : ''
}`}
onClick={() => handleToggleSubtask(subtask)}
>
<span className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 ${
subtask.completed ? 'bg-accent border-accent' : 'border-text-secondary'
}`}>
{subtask.completed && <span className="text-white text-xs"></span>}
</span>
<span className={`text-sm flex-1 ${subtask.completed ? 'line-through text-text-secondary' : ''}`}>
{subtask.title}
</span>
</div>
))}
</div>
{/* Add subtask */}
<div className="flex gap-2 mt-2">
<input
type="text"
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddSubtask(); }}
placeholder="Add a subtask..."
className="flex-1 bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
<button
onClick={handleAddSubtask}
disabled={!newSubtaskTitle.trim()}
className="px-3 py-2 bg-accent/10 text-accent rounded-lg text-sm font-medium hover:bg-accent/20 transition-colors disabled:opacity-50"
>
+
</button>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-border sticky bottom-0 bg-card">
<button
onClick={handleDelete}
className="text-sm text-red-500 hover:text-red-600 transition-colors"
>
Delete Task
</button>
<div className="flex gap-2">
<button
onClick={() => setShowEditTaskModal(false)}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!title.trim()}
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Save Changes
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useApp } from './AppProvider';
export default function FilterPanel() {
const {
filterStatus, setFilterStatus, filterPriority, setFilterPriority,
filterDueBefore, setFilterDueBefore, filterDueAfter, setFilterDueAfter,
filterCompleted, setFilterCompleted, refreshTasks,
} = useApp();
const handleApply = () => {
refreshTasks();
};
const handleReset = () => {
setFilterStatus('');
setFilterPriority('');
setFilterDueBefore('');
setFilterDueAfter('');
setFilterCompleted('');
};
return (
<div className="bg-card border-b border-border px-4 py-3 animate-slide-in">
<div className="flex flex-wrap items-center gap-4">
{/* Status */}
<div>
<label className="block text-xs text-text-secondary mb-1">Status</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="bg-content border border-border rounded px-2 py-1.5 text-sm focus:outline-none focus:border-accent"
>
<option value="">All</option>
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
{/* Priority */}
<div>
<label className="block text-xs text-text-secondary mb-1">Priority</label>
<select
value={filterPriority}
onChange={(e) => setFilterPriority(e.target.value)}
className="bg-content border border-border rounded px-2 py-1.5 text-sm focus:outline-none focus:border-accent"
>
<option value="">All</option>
<option value="urgent">🔴 Urgent</option>
<option value="high">🟠 High</option>
<option value="medium">🟡 Medium</option>
<option value="low">🟢 Low</option>
</select>
</div>
{/* Due before */}
<div>
<label className="block text-xs text-text-secondary mb-1">Due before</label>
<input
type="date"
value={filterDueBefore}
onChange={(e) => setFilterDueBefore(e.target.value)}
className="bg-content border border-border rounded px-2 py-1.5 text-sm focus:outline-none focus:border-accent"
/>
</div>
{/* Due after */}
<div>
<label className="block text-xs text-text-secondary mb-1">Due after</label>
<input
type="date"
value={filterDueAfter}
onChange={(e) => setFilterDueAfter(e.target.value)}
className="bg-content border border-border rounded px-2 py-1.5 text-sm focus:outline-none focus:border-accent"
/>
</div>
{/* Completed */}
<div>
<label className="block text-xs text-text-secondary mb-1">Completed</label>
<select
value={filterCompleted}
onChange={(e) => setFilterCompleted(e.target.value)}
className="bg-content border border-border rounded px-2 py-1.5 text-sm focus:outline-none focus:border-accent"
>
<option value="">All</option>
<option value="true">Completed</option>
<option value="false">Not completed</option>
</select>
</div>
{/* Actions */}
<div className="flex gap-2 ml-auto">
<button
onClick={handleReset}
className="px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
Reset
</button>
<button
onClick={handleApply}
className="px-4 py-1.5 bg-accent hover:bg-accent-hover text-white rounded text-sm transition-colors"
>
Apply
</button>
</div>
</div>
</div>
);
}

111
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,111 @@
'use client';
import { useApp } from './AppProvider';
import { useState, useEffect, useRef } from 'react';
export default function Header() {
const {
view, selectedProject, projects, searchQuery, setSearchQuery,
showNewTaskModal, setShowNewTaskModal,
filterStatus, setFilterStatus, filterPriority, setFilterPriority,
filterDueBefore, setFilterDueBefore, filterDueAfter, setFilterDueAfter,
filterCompleted, setFilterCompleted,
refreshTasks,
} = useApp();
const [showFilters, setShowFilters] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
searchRef.current?.focus();
}
if (e.key === 'n' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setShowNewTaskModal(true);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [setShowNewTaskModal]);
const selectedProjectName = projects.find(p => p.id === selectedProject)?.name || 'All Projects';
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
refreshTasks();
};
const handleClearFilters = () => {
setFilterStatus('');
setFilterPriority('');
setFilterDueBefore('');
setFilterDueAfter('');
setFilterCompleted('');
setSearchQuery('');
refreshTasks();
};
const hasActiveFilters = filterStatus || filterPriority || filterDueBefore || filterDueAfter || filterCompleted || searchQuery;
return (
<header className="bg-card border-b border-border px-4 py-3 flex items-center gap-4">
{/* Search */}
<form onSubmit={handleSearch} className="flex-1 max-w-md">
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">🔍</span>
<input
ref={searchRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tasks... (⌘K)"
className="w-full bg-content border border-border rounded-lg pl-9 pr-4 py-2 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
</div>
</form>
{/* Project name */}
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span>📋</span>
<span className="font-medium">{selectedProjectName}</span>
</div>
{/* Filter toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm transition-colors ${
showFilters || hasActiveFilters
? 'bg-accent/10 text-accent'
: 'text-text-secondary hover:bg-content'
}`}
>
<span></span>
Filters
{hasActiveFilters && (
<span className="w-2 h-2 bg-accent rounded-full" />
)}
</button>
{/* Clear filters */}
{hasActiveFilters && (
<button
onClick={handleClearFilters}
className="text-xs text-text-secondary hover:text-red-500 transition-colors"
>
Clear all
</button>
)}
{/* New task button (mobile) */}
<button
onClick={() => setShowNewTaskModal(true)}
className="bg-accent hover:bg-accent-hover text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
+ New Task
</button>
</header>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { useApp, type Task } from './AppProvider';
import { useState, useCallback } from 'react';
import { format, isToday, isTomorrow, isPast } from 'date-fns';
interface KanbanTask extends Task {
projectColor?: string;
}
export default function KanbanBoard() {
const { tasks, projects, setSelectedTask, setShowEditTaskModal, setEditingTask, refreshTasks } = useApp();
const [draggedTask, setDraggedTask] = useState<string | null>(null);
// Add project info
const kanbanTasks: KanbanTask[] = tasks.map(task => {
const project = projects.find(p => p.id === task.projectId);
return { ...task, projectColor: project?.color };
});
const todoTasks = kanbanTasks.filter(t => t.status === 'todo');
const inProgressTasks = kanbanTasks.filter(t => t.status === 'in_progress');
const doneTasks = kanbanTasks.filter(t => t.status === 'done');
const columns: { key: string; title: string; tasks: KanbanTask[]; icon: string; color: string }[] = [
{ key: 'todo', title: 'To Do', tasks: todoTasks, icon: '📋', color: 'border-gray-300' },
{ key: 'in_progress', title: 'In Progress', tasks: inProgressTasks, icon: '🔄', color: 'border-blue-300' },
{ key: 'done', title: 'Done', tasks: doneTasks, icon: '✅', color: 'border-green-300' },
];
const handleDragStart = (taskId: string) => {
setDraggedTask(taskId);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDrop = async (status: string, e: React.DragEvent) => {
e.preventDefault();
if (!draggedTask) return;
try {
await fetch(`/api/tasks/${draggedTask}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
refreshTasks();
// Refresh kanban data
const res = await fetch(`/api/kanban`);
const data = await res.json();
} catch (error) {
console.error('Failed to update task status:', error);
}
setDraggedTask(null);
};
const handleTaskClick = (task: KanbanTask) => {
setSelectedTask(task);
setEditingTask(task);
setShowEditTaskModal(true);
};
const getDueDateInfo = (dueDate: string | null) => {
if (!dueDate) return null;
// DATE type returns "YYYY-MM-DD" - parse as local date directly
const [year, month, day] = dueDate.split('-').map(Number);
const date = new Date(year, month - 1, day);
if (isToday(date)) return { label: 'Today', className: 'text-[var(--priority-urgent)] font-medium' };
if (isTomorrow(date)) return { label: 'Tomorrow', className: 'text-[var(--priority-high)]' };
if (isPast(date)) return { label: format(date, 'MMM d'), className: 'text-[var(--priority-urgent)] font-medium' };
return { label: format(date, 'MMM d'), className: 'text-text-secondary' };
};
const priorityConfig = {
urgent: { bg: 'bg-[var(--priority-urgent)]/10', text: 'text-[var(--priority-urgent)]' },
high: { bg: 'bg-[var(--priority-high)]/10', text: 'text-[var(--priority-high)]' },
medium: { bg: 'bg-[var(--priority-medium)]/10', text: 'text-[var(--priority-medium)]' },
low: { bg: 'bg-[var(--priority-low)]/10', text: 'text-[var(--priority-low)]' },
};
return (
<div className="flex-1 overflow-x-auto overflow-y-hidden p-4">
<div className="flex gap-4 h-full min-w-[900px]">
{columns.map((column) => (
<div
key={column.key}
className="flex-1 flex flex-col bg-content rounded-xl border border-border"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(column.key, e)}
>
{/* Column header */}
<div className={`p-3 border-b-2 ${column.color} rounded-t-xl`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span>{column.icon}</span>
<span className="font-semibold text-sm">{column.title}</span>
<span className="text-xs text-text-secondary bg-content px-2 py-0.5 rounded-full">
{column.tasks.length}
</span>
</div>
</div>
</div>
{/* Tasks */}
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{column.tasks.map((task) => {
const dueInfo = getDueDateInfo(task.dueDate);
const prio = priorityConfig[task.priority];
return (
<div
key={task.id}
draggable
onDragStart={() => handleDragStart(task.id)}
onClick={() => handleTaskClick(task)}
className={`p-3 bg-card border border-border rounded-lg cursor-grab hover:shadow-md transition-all ${
draggedTask === task.id ? 'opacity-50 scale-95' : ''
} ${task.completed ? 'opacity-60' : ''}`}
>
{/* Project indicator */}
{task.projectColor && (
<div className="flex items-center gap-2 mb-2">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: task.projectColor }}
/>
</div>
)}
{/* Title */}
<p className={`text-sm font-medium mb-1 ${task.completed ? 'line-through text-text-secondary' : ''}`}>
{task.title}
</p>
{/* Description preview */}
{task.description && (
<p className="text-xs text-text-secondary truncate mb-2">{task.description}</p>
)}
{/* Footer */}
<div className="flex items-center justify-between mt-2">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${prio.bg} ${prio.text}`}>
{task.priority}
</span>
{dueInfo && (
<span className={`text-xs ${dueInfo.className}`}>
{dueInfo.label}
</span>
)}
{task.recurrenceRule !== 'none' && (
<span title="Recurring">🔄</span>
)}
</div>
</div>
);
})}
{column.tasks.length === 0 && (
<div className="text-center py-8 text-text-secondary text-sm">
Drop tasks here
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { useApp } from './AppProvider';
import TaskCard from './TaskCard';
export default function ListView() {
const { tasks, projects, filterStatus, filterPriority, filterCompleted } = useApp();
// Add project info to each task
const tasksWithProject = tasks.map(task => {
const project = projects.find(p => p.id === task.projectId);
return { ...task, projectColor: project?.color, projectName: project?.name };
});
// Apply client-side filters for status, priority, completed
const filteredTasks = tasksWithProject.filter(task => {
if (filterStatus && task.status !== filterStatus) return false;
if (filterPriority && task.priority !== filterPriority) return false;
if (filterCompleted === 'true' && !task.completed) return false;
if (filterCompleted === 'false' && task.completed) return false;
return true;
});
// Group by status
const todoTasks = filteredTasks.filter(t => t.status === 'todo');
const inProgressTasks = filteredTasks.filter(t => t.status === 'in_progress');
const doneTasks = filteredTasks.filter(t => t.status === 'done');
const renderSection = (title: string, tasks: typeof filteredTasks, icon: string) => {
if (tasks.length === 0 && filterStatus) return null;
return (
<div className="mb-6">
<h3 className="text-sm font-semibold text-text-secondary mb-2 flex items-center gap-2">
<span>{icon}</span>
{title}
<span className="text-xs text-text-secondary font-normal">({tasks.length})</span>
</h3>
<div className="space-y-2">
{tasks.map(task => (
<TaskCard
key={task.id}
task={task}
projectColor={task.projectColor}
projectName={task.projectName}
/>
))}
</div>
</div>
);
};
return (
<div className="flex-1 overflow-y-auto p-4">
{filteredTasks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-text-secondary">
<span className="text-4xl mb-4">📝</span>
<p className="text-lg font-medium">No tasks yet</p>
<p className="text-sm mt-1">Create your first task to get started!</p>
</div>
) : (
<>
{renderSection('To Do', todoTasks, '📋')}
{renderSection('In Progress', inProgressTasks, '🔄')}
{renderSection('Done', doneTasks, '✅')}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,234 @@
'use client';
import { useApp } from './AppProvider';
import { useState, useEffect, useRef } from 'react';
import { format } from 'date-fns';
export default function NewTaskModal() {
const {
showNewTaskModal, setShowNewTaskModal,
projects, selectedProject, refreshTasks,
} = useApp();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [projectId, setProjectId] = useState(selectedProject || '');
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium');
const [dueDate, setDueDate] = useState('');
const [status, setStatus] = useState<'todo' | 'in_progress'>('todo');
const [showRecurrence, setShowRecurrence] = useState(false);
const [recurrenceRule, setRecurrenceRule] = useState<'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly'>('none');
const [recurrenceInterval, setRecurrenceInterval] = useState(1);
const modalRef = useRef<HTMLDivElement>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (showNewTaskModal && titleInputRef.current) {
titleInputRef.current.focus();
}
}, [showNewTaskModal]);
useEffect(() => {
if (showNewTaskModal) {
setProjectId(selectedProject || '');
}
}, [showNewTaskModal, selectedProject]);
const handleCreate = async () => {
if (!title.trim()) return;
try {
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title.trim(),
description: description.trim(),
projectId: projectId || null,
priority,
dueDate: dueDate || null,
status,
recurrenceRule,
recurrenceInterval,
}),
});
setTitle('');
setDescription('');
setPriority('medium');
setDueDate('');
setStatus('todo');
setShowRecurrence(false);
setRecurrenceRule('none');
setRecurrenceInterval(1);
setShowNewTaskModal(false);
refreshTasks();
} catch (error) {
console.error('Failed to create task:', error);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setShowNewTaskModal(false);
}
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleCreate();
}
};
if (!showNewTaskModal) return null;
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 animate-fade-in"
onClick={(e) => { if (e.target === e.currentTarget) setShowNewTaskModal(false); }}
>
<div
ref={modalRef}
className="bg-card rounded-xl w-full max-w-md mx-4 shadow-xl animate-slide-in"
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-bold">New Task</h2>
<button
onClick={() => setShowNewTaskModal(false)}
className="text-text-secondary hover:text-text-primary transition-colors text-xl"
>
</button>
</div>
{/* Body */}
<div className="p-4 space-y-3">
{/* Title */}
<input
ref={titleInputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Task title"
className="w-full bg-content border border-border rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
{/* Description */}
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)"
rows={2}
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors resize-none"
/>
{/* Project */}
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
>
<option value="">No Project</option>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{/* Priority & Due Date row */}
<div className="grid grid-cols-2 gap-3">
<select
value={priority}
onChange={(e) => setPriority(e.target.value as any)}
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
>
<option value="low">🟢 Low</option>
<option value="medium">🟡 Medium</option>
<option value="high">🟠 High</option>
<option value="urgent">🔴 Urgent</option>
</select>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
</div>
{/* Status */}
<select
value={status}
onChange={(e) => setStatus(e.target.value as any)}
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
>
<option value="todo">To Do</option>
<option value="in_progress">In Progress</option>
</select>
{/* Recurrence toggle */}
<div className="flex items-center justify-between">
<button
onClick={() => setShowRecurrence(!showRecurrence)}
className={`text-sm flex items-center gap-2 ${showRecurrence ? 'text-accent' : 'text-text-secondary'}`}
>
🔄 Recurring task
</button>
</div>
{/* Recurrence options */}
{showRecurrence && (
<div className="space-y-2 animate-slide-in">
<select
value={recurrenceRule}
onChange={(e) => setRecurrenceRule(e.target.value as any)}
className="w-full bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
>
<option value="none">Not recurring</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Bi-weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
{recurrenceRule !== 'none' && (
<div className="flex items-center gap-2">
<span className="text-sm text-text-secondary">Every</span>
<input
type="number"
min={1}
max={365}
value={recurrenceInterval}
onChange={(e) => setRecurrenceInterval(Number(e.target.value))}
className="w-20 bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
<span className="text-sm text-text-secondary">
{recurrenceInterval === 1 ? '' : recurrenceInterval + ' '}
{recurrenceRule === 'daily' ? 'day' : recurrenceRule === 'weekly' ? 'week' : recurrenceRule === 'biweekly' ? 'weeks' : recurrenceRule === 'monthly' ? 'month' : recurrenceRule === 'yearly' ? 'year' : ''}
{recurrenceInterval > 1 ? 's' : ''}
</span>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-border">
<button
onClick={() => setShowNewTaskModal(false)}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={!title.trim()}
className="px-4 py-2 bg-accent hover:bg-accent-hover text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Create Task
</button>
</div>
</div>
</div>
);
}

221
src/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,221 @@
'use client';
import { useApp } from './AppProvider';
import { useState } from 'react';
export default function Sidebar() {
const {
view, setView, selectedProject, setSelectedProject,
sidebarOpen, setSidebarOpen, showNewProjectModal, setShowNewProjectModal,
showNewTaskModal, setShowNewTaskModal, projects, setProjects, refreshTasks, refreshProjects,
} = useApp();
const [projectName, setProjectName] = useState('');
const [projectDescription, setProjectDescription] = useState('');
const [projectColor, setProjectColor] = useState('#3b82f6');
const [creatingProject, setCreatingProject] = useState(false);
const views: { key: typeof view; label: string; icon: string }[] = [
{ key: 'list', label: 'List View', icon: '☰' },
{ key: 'kanban', label: 'Kanban Board', icon: '▦' },
{ key: 'calendar', label: 'Calendar', icon: '📅' },
];
const handleNewProject = async () => {
if (!projectName.trim()) return;
try {
await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: projectName.trim(),
description: projectDescription.trim(),
color: projectColor,
}),
});
setProjectName('');
setProjectDescription('');
setProjectColor('#3b82f6');
setCreatingProject(false);
refreshProjects();
} catch (error) {
console.error('Failed to create project:', error);
}
};
const handleDeleteProject = async (id: string) => {
if (!confirm('Delete this project and all its tasks?')) return;
try {
await fetch(`/api/projects/${id}`, { method: 'DELETE' });
if (selectedProject === id) setSelectedProject(null);
refreshProjects();
refreshTasks();
} catch (error) {
console.error('Failed to delete project:', error);
}
};
return (
<div className={`flex flex-col h-full bg-sidebar text-white transition-all duration-300 ${sidebarOpen ? 'w-64' : 'w-16'}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
{sidebarOpen && (
<div className="flex items-center gap-2">
<span className="text-xl font-bold">VixTix</span>
</div>
)}
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-1.5 rounded-lg hover:bg-sidebar-hover transition-colors"
>
<span className="text-lg">{sidebarOpen ? '◀' : '▶'}</span>
</button>
</div>
{/* New Task Button */}
<div className="p-3">
<button
onClick={() => setShowNewTaskModal(true)}
className="w-full flex items-center justify-center gap-2 bg-accent hover:bg-accent-hover text-white py-2.5 px-4 rounded-lg transition-colors font-medium"
>
<span className="text-lg">+</span>
{sidebarOpen && <span>New Task</span>}
</button>
</div>
{/* Views */}
{sidebarOpen && (
<div className="px-3 mb-4">
<p className="text-xs text-gray-400 uppercase tracking-wider mb-2 px-2">Views</p>
<div className="space-y-1">
{views.map((v) => (
<button
key={v.key}
onClick={() => setView(v.key)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm ${
view === v.key
? 'bg-sidebar-active text-white'
: 'text-gray-300 hover:bg-sidebar-hover hover:text-white'
}`}
>
<span className="text-base">{v.icon}</span>
{v.label}
</button>
))}
</div>
</div>
)}
{/* Projects */}
<div className="flex-1 overflow-y-auto px-3">
{sidebarOpen && (
<div className="flex items-center justify-between mb-2 px-2">
<p className="text-xs text-gray-400 uppercase tracking-wider">Projects</p>
<button
onClick={() => setCreatingProject(!creatingProject)}
className="text-gray-400 hover:text-white transition-colors"
title="New Project"
>
+
</button>
</div>
)}
{/* New project form */}
{creatingProject && sidebarOpen && (
<div className="mb-3 p-3 bg-sidebar-hover rounded-lg animate-slide-in">
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="Project name"
className="w-full bg-transparent border border-white/20 rounded px-2 py-1.5 text-sm text-white placeholder-gray-500 mb-2 focus:outline-none focus:border-accent"
autoFocus
/>
<input
type="text"
value={projectDescription}
onChange={(e) => setProjectDescription(e.target.value)}
placeholder="Description (optional)"
className="w-full bg-transparent border border-white/20 rounded px-2 py-1.5 text-sm text-white placeholder-gray-500 mb-2 focus:outline-none focus:border-accent"
/>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-400">Color:</span>
<input
type="color"
value={projectColor}
onChange={(e) => setProjectColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border-0 bg-transparent"
/>
</div>
<div className="flex gap-2">
<button
onClick={handleNewProject}
className="flex-1 bg-accent hover:bg-accent-hover text-white py-1.5 rounded text-sm transition-colors"
>
Create
</button>
<button
onClick={() => { setCreatingProject(false); setProjectName(''); }}
className="flex-1 bg-gray-600 hover:bg-gray-500 text-white py-1.5 rounded text-sm transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Project list */}
<div className="space-y-0.5">
{/* All Projects option */}
<button
onClick={() => setSelectedProject(null)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm ${
!selectedProject
? 'bg-sidebar-active text-white'
: 'text-gray-300 hover:bg-sidebar-hover hover:text-white'
}`}
>
<span className="text-base">📋</span>
{sidebarOpen && <span className="truncate">All Projects</span>}
</button>
{projects.map((project) => (
<div
key={project.id}
className={`group flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm ${
selectedProject === project.id
? 'bg-sidebar-active text-white'
: 'text-gray-300 hover:bg-sidebar-hover hover:text-white'
}`}
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: project.color }}
/>
{sidebarOpen && (
<>
<span className="truncate flex-1 text-left">{project.name}</span>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteProject(project.id); }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-400 transition-all"
title="Delete project"
>
</button>
</>
)}
</div>
))}
</div>
</div>
{/* Footer */}
{sidebarOpen && (
<div className="p-3 border-t border-white/10 text-xs text-gray-500 text-center">
VixTix v1.0
</div>
)}
</div>
);
}

153
src/components/TaskCard.tsx Normal file
View File

@@ -0,0 +1,153 @@
'use client';
import { useApp, type Task } from './AppProvider';
import { format, isToday, isTomorrow, isPast } from 'date-fns';
interface TaskCardProps {
task: Task;
projectColor?: string;
projectName?: string;
}
const priorityConfig = {
urgent: { color: 'text-[var(--priority-urgent)]', bg: 'bg-[var(--priority-urgent)]/10', label: 'Urgent', icon: '🔴' },
high: { color: 'text-[var(--priority-high)]', bg: 'bg-[var(--priority-high)]/10', label: 'High', icon: '🟠' },
medium: { color: 'text-[var(--priority-medium)]', bg: 'bg-[var(--priority-medium)]/10', label: 'Medium', icon: '🟡' },
low: { color: 'text-[var(--priority-low)]', bg: 'bg-[var(--priority-low)]/10', label: 'Low', icon: '🟢' },
};
export default function TaskCard({ task, projectColor, projectName }: TaskCardProps) {
const { setSelectedTask, setShowEditTaskModal, setEditingTask, refreshTasks } = useApp();
const handleToggle = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
const res = await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'toggle-done' }),
});
if (res.ok) {
refreshTasks();
}
} catch (error) {
console.error('Failed to toggle task:', error);
}
};
const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('Delete this task?')) return;
try {
await fetch(`/api/tasks/${task.id}`, { method: 'DELETE' });
refreshTasks();
} catch (error) {
console.error('Failed to delete task:', error);
}
};
const handleClick = () => {
setSelectedTask(task);
setEditingTask(task);
setShowEditTaskModal(true);
};
const priority = priorityConfig[task.priority];
// DATE type returns "YYYY-MM-DD" - parse as local date directly
let dueDate: Date | null = null;
if (task.dueDate) {
const [year, month, day] = task.dueDate.split('-').map(Number);
dueDate = new Date(year, month - 1, day);
}
let dueDateLabel = '';
let dueDateClass = '';
if (dueDate) {
if (isToday(dueDate)) {
dueDateLabel = 'Today';
dueDateClass = 'text-[var(--priority-urgent)] font-medium';
} else if (isTomorrow(dueDate)) {
dueDateLabel = 'Tomorrow';
dueDateClass = 'text-[var(--priority-high)]';
} else if (isPast(dueDate) && !task.completed) {
dueDateLabel = format(dueDate, 'MMM d');
dueDateClass = 'text-[var(--priority-urgent)] font-medium';
} else {
dueDateLabel = format(dueDate, 'MMM d');
dueDateClass = 'text-text-secondary';
}
}
const hasRecurrence = task.recurrenceRule !== 'none';
return (
<div
onClick={handleClick}
className={`group flex items-center gap-3 p-3 bg-card border border-border rounded-lg hover:shadow-sm transition-all cursor-pointer ${
task.completed ? 'opacity-60' : ''
}`}
>
{/* Checkbox */}
<button
onClick={handleToggle}
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
task.completed
? 'bg-accent border-accent'
: 'border-text-secondary hover:border-accent'
}`}
>
{task.completed && <span className="text-white text-xs"></span>}
</button>
{/* Project indicator */}
{projectColor && (
<span
className="w-2 h-8 rounded-full flex-shrink-0"
style={{ backgroundColor: projectColor }}
/>
)}
{/* Task content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`font-medium truncate ${task.completed ? 'line-through text-text-secondary' : ''}`}>
{task.title}
</span>
{hasRecurrence && <span title="Recurring">🔄</span>}
</div>
{task.description && (
<p className="text-xs text-text-secondary truncate mt-0.5">{task.description}</p>
)}
</div>
{/* Priority badge */}
<span className={`px-2 py-0.5 rounded text-xs font-medium ${priority.bg} ${priority.color} flex-shrink-0`}>
{priority.icon} {priority.label}
</span>
{/* Due date */}
{dueDate && (
<span className={`text-xs flex-shrink-0 ${dueDateClass}`}>
{dueDateLabel}
</span>
)}
{/* Status badge */}
{task.status !== 'done' && (
<span className={`text-xs px-2 py-0.5 rounded flex-shrink-0 ${
task.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'
}`}>
{task.status === 'in_progress' ? 'In Progress' : 'Todo'}
</span>
)}
{/* Actions */}
<button
onClick={handleDelete}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0"
>
</button>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { format, isToday, isTomorrow, isPast, isFuture } from 'date-fns';
describe('Date parsing from DATE type strings', () => {
it('should parse a DATE string and create a correct local Date', () => {
const dateStr = '2026-05-02';
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(year, month - 1, day);
expect(date.getFullYear()).toBe(2026);
expect(date.getMonth()).toBe(4); // May is 0-indexed
expect(date.getDate()).toBe(2);
});
it('should handle month boundaries correctly', () => {
const dateStr = '2026-03-31';
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(year, month - 1, day);
expect(date.getFullYear()).toBe(2026);
expect(date.getMonth()).toBe(2); // March
expect(date.getDate()).toBe(31);
});
it('should handle leap year dates correctly', () => {
const dateStr = '2024-02-29';
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(year, month - 1, day);
expect(date.getFullYear()).toBe(2024);
expect(date.getMonth()).toBe(1); // February
expect(date.getDate()).toBe(29);
});
});
describe('Date formatting', () => {
it('should format a Date as "YYYY-MM-DD"', () => {
const date = new Date(2026, 4, 2); // May 2, 2026
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const formatted = `${year}-${month}-${day}`;
expect(formatted).toBe('2026-05-02');
});
it('should format single-digit months and days with leading zeros', () => {
const date = new Date(2026, 0, 5); // January 5, 2026
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const formatted = `${year}-${month}-${day}`;
expect(formatted).toBe('2026-01-05');
});
});
describe('Date utilities from date-fns', () => {
it('should identify today', () => {
const today = new Date();
expect(isToday(today)).toBe(true);
});
it('should identify tomorrow', () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
expect(isTomorrow(tomorrow)).toBe(true);
});
it('should identify past dates', () => {
const past = new Date();
past.setDate(past.getDate() - 1);
expect(isPast(past)).toBe(true);
});
it('should identify future dates', () => {
const future = new Date();
future.setDate(future.getDate() + 1);
expect(isFuture(future)).toBe(true);
});
it('should format dates correctly', () => {
const date = new Date(2026, 4, 2); // May 2, 2026
expect(format(date, 'MMM d')).toBe('May 2');
expect(format(date, 'MMMM d, yyyy')).toBe('May 2, 2026');
});
});

124
src/server/db/migrate.ts Normal file
View File

@@ -0,0 +1,124 @@
import { Pool } from 'pg';
const connectionString = process.env.DATABASE_URL || 'postgresql://vixtix:vixtix_secret@localhost:5433/vixtix';
const pool = new Pool({ connectionString });
async function migrate() {
console.log('Running migrations...');
const client = await pool.connect();
try {
await client.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
// Create enums - use DO block to handle duplicates
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'priority') THEN
CREATE TYPE priority AS ENUM ('low', 'medium', 'high', 'urgent');
END IF;
END $$;
`);
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'status') THEN
CREATE TYPE status AS ENUM ('todo', 'in_progress', 'done');
END IF;
END $$;
`);
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'recurrence') THEN
CREATE TYPE recurrence AS ENUM ('daily', 'weekly', 'biweekly', 'monthly', 'yearly', 'none');
END IF;
END $$;
`);
await client.query(`
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL, description TEXT DEFAULT '', color TEXT DEFAULT '#3b82f6',
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
`);
await client.query(`
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES projects(id),
title TEXT NOT NULL, description TEXT DEFAULT '',
completed BOOLEAN DEFAULT FALSE NOT NULL,
priority priority DEFAULT 'medium' NOT NULL,
due_date DATE,
status status DEFAULT 'todo' NOT NULL,
parent_task_id UUID REFERENCES tasks(id),
recurrence_rule recurrence DEFAULT 'none' NOT NULL,
recurrence_interval INTEGER DEFAULT 1 NOT NULL,
next_occurrence DATE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id);
CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
CREATE INDEX IF NOT EXISTS idx_tasks_recurrence ON tasks(recurrence_rule);
`);
const count = await client.query('SELECT COUNT(*) FROM projects');
if (parseInt(count.rows[0].count) === 0) {
console.log('Seeding initial data...');
await client.query(`
INSERT INTO projects (name, description, color, sort_order) VALUES
('Personal', 'Personal tasks and goals', '#3b82f6', 1),
('Work', 'Work-related tasks', '#10b981', 2),
('Health', 'Health and fitness', '#f59e0b', 3),
('Finance', 'Financial tasks and tracking', '#8b5cf6', 4);
`);
// Use DATE arithmetic instead of INTERVAL
await client.query(`
INSERT INTO tasks (project_id, title, description, priority, status, due_date, sort_order)
SELECT p.id, t.title, t.description, t.priority::priority, t.status::status, t.due_date, t.sort_order
FROM (
VALUES
('Personal', 'Set up daily routine', 'Morning meditation, exercise, and planning', 'high', 'todo', CURRENT_DATE + 1, 1),
('Personal', 'Read 30 minutes', 'Read a book or articles', 'medium', 'todo', CURRENT_DATE + 2, 2),
('Personal', 'Clean apartment', 'Deep clean kitchen and bathrooms', 'medium', 'in_progress', CURRENT_DATE + 3, 3),
('Work', 'Review sprint backlog', 'Prioritize tasks for next sprint', 'high', 'todo', CURRENT_DATE + 1, 1),
('Work', 'Update documentation', 'Add API docs for new endpoints', 'medium', 'in_progress', CURRENT_DATE + 5, 2),
('Work', 'Code review', 'Review pull requests from team', 'low', 'done', CURRENT_DATE - 1, 3),
('Health', 'Gym workout', 'Upper body strength training', 'high', 'todo', CURRENT_DATE + 1, 1),
('Health', 'Meal prep', 'Prepare healthy meals for the week', 'medium', 'todo', CURRENT_DATE + 2, 2),
('Health', 'Track water intake', 'Drink at least 8 glasses of water', 'low', 'done', CURRENT_DATE - 1, 3),
('Finance', 'Review monthly budget', 'Check spending and adjust categories', 'high', 'todo', CURRENT_DATE + 3, 1),
('Finance', 'Pay bills', 'Electricity, internet, phone', 'urgent', 'todo', CURRENT_DATE + 1, 2),
('Finance', 'Investment review', 'Check portfolio performance', 'medium', 'todo', CURRENT_DATE + 7, 3)
) AS t(name, title, description, priority, status, due_date, sort_order)
JOIN projects p ON p.name = t.name;
`);
console.log('Seed data inserted successfully!');
} else {
console.log('Database already seeded.');
}
console.log('Migrations completed successfully!');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
migrate();

8
src/test/setup.ts Normal file
View File

@@ -0,0 +1,8 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// Auto cleanup after each test
afterEach(() => {
cleanup();
});