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

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.next
.git
.gitignore
*.md
upload_to_gitea.py
.env
.hermes
Dockerfile
docker-compose.yml
.dockerignore

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,47 @@
# VixTix Implementation Plan
## Architecture
- Next.js 16 App Router (TypeScript + Tailwind)
- PostgreSQL database (Docker container, port 5433)
- Drizzle ORM for database access
- RESTful API routes under `/app/api/`
- Multi-stage Docker build with PostgreSQL
## Database Schema
- **projects**: id, name, description, color, sort_order, created_at, updated_at
- **tasks**: id, project_id (FK), title, description, completed, priority (low/medium/high/urgent), due_date, status (todo/in_progress/done), parent_task_id (FK, self-ref), recurrence_rule (daily/weekly/biweekly/monthly/yearly/none), recurrence_interval, next_occurrence, sort_order, created_at, updated_at
- **Recurring tasks**: Handled inline in tasks table. When completed, if recurrence_rule != 'none', create new task with same data, next_occurrence as new due_date, and increment sort_order.
## API Routes
- `GET /api/tasks?project=&search=&status=&priority=&due_before=&due_after=&completed=` — list with filters
- `POST /api/tasks` — create task
- `PUT /api/tasks/[id]` — update task
- `DELETE /api/tasks/[id]` — delete task
- `PATCH /api/tasks/[id]/done` — toggle completion (handles recurrence)
- `GET /api/projects` — list projects
- `POST /api/projects` — create project
- `PUT /api/projects/[id]` — update project
- `DELETE /api/projects/[id]` — delete project
- `GET /api/kanban` — tasks grouped by status
## Frontend Views
1. **Sidebar**: Projects list (click to filter), New Task button, view switcher (List/Kanban/Calendar)
2. **List View**: Filterable task list with search, sort by due date/priority. Click task opens detail panel.
3. **Kanban Board**: Three columns (Todo/In Progress/Done). Click to move between statuses.
4. **Calendar View**: Monthly grid showing tasks on due dates. Click date to create task.
5. **Detail Panel**: Right-side panel for viewing/editing task details (title, description, project, priority, due date, recurrence, subtasks).
6. **New Task Modal**: Quick add from header.
7. **Recurring Task Modal**: Configure recurrence rule and interval.
## UI Design
- Clean, modern productivity app aesthetic
- Dark sidebar (#1a1a2e), white content area
- Color-coded priority badges (red=urgent, orange=high, yellow=medium, green=low)
- Project color indicators
- Responsive layout
## Docker
- Multi-stage build: builder + runner
- PostgreSQL container on port 5433 (avoiding conflicts)
- App on port 3070
- Docker Compose for orchestration

5
AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the Next.js app
RUN npm run build
# Production stage
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
# Install postgres client, netcat, and tsx for migrations
RUN apk add --no-cache postgresql-client netcat-openbsd && \
npm install -g tsx
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy the built app from builder
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone/ ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/package.json ./
# Copy migration script (for initial setup)
COPY --chown=nextjs:nodejs src/server/db/migrate.ts ./migrate.ts
# Create startup script
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
USER nextjs
EXPOSE 3000
CMD ["/app/start.sh"]

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: vixtix-db
restart: unless-stopped
environment:
POSTGRES_USER: vixtix
POSTGRES_PASSWORD: vixtix_secret
POSTGRES_DB: vixtix
ports:
- "5433:5432"
volumes:
- vixtix-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vixtix"]
interval: 5s
timeout: 5s
retries: 5
vixtix:
image: vixtix:latest
container_name: vixtix
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "3070:3000"
environment:
DATABASE_URL: postgresql://vixtix:vixtix_secret@postgres:5432/vixtix
DB_HOST: postgres
DB_PORT: 5432
volumes:
vixtix-data:

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;

6756
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "vixtix",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"date-fns": "^4.1.0",
"next": "16.2.4",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

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();
});

17
start.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -e
echo "Waiting for PostgreSQL at $DB_HOST:$DB_PORT..."
for i in $(seq 1 60); do
if nc -z "$DB_HOST" "$DB_PORT" 2>/dev/null; then
echo "PostgreSQL is ready"
break
fi
echo "Waiting... $i"
sleep 1
done
echo "Running migrations..."
tsx ./migrate.ts
echo "Starting VixTix..."
exec node server.js

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

17
vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
});