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:
11
.dockerignore
Normal file
11
.dockerignore
Normal 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
41
.gitignore
vendored
Normal 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
|
||||
47
.hermes/plans/vixtix-build.md
Normal file
47
.hermes/plans/vixtix-build.md
Normal 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
5
AGENTS.md
Normal 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 -->
|
||||
52
Dockerfile
Normal file
52
Dockerfile
Normal 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
36
README.md
Normal 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
37
docker-compose.yml
Normal 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
18
eslint.config.mjs
Normal 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
7
next.config.ts
Normal 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
6756
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal 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
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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 |
64
src/app/api/kanban/route.ts
Normal file
64
src/app/api/kanban/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
95
src/app/api/projects/[id]/route.ts
Normal file
95
src/app/api/projects/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
63
src/app/api/projects/route.ts
Normal file
63
src/app/api/projects/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
214
src/app/api/tasks/[id]/route.ts
Normal file
214
src/app/api/tasks/[id]/route.ts
Normal 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
144
src/app/api/tasks/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
81
src/app/globals.css
Normal file
81
src/app/globals.css
Normal 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
22
src/app/layout.tsx
Normal 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
48
src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
src/components/AppProvider.tsx
Normal file
143
src/components/AppProvider.tsx
Normal 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>;
|
||||
}
|
||||
160
src/components/CalendarView.tsx
Normal file
160
src/components/CalendarView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
340
src/components/EditTaskModal.tsx
Normal file
340
src/components/EditTaskModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/FilterPanel.tsx
Normal file
112
src/components/FilterPanel.tsx
Normal 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
111
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
src/components/KanbanBoard.tsx
Normal file
173
src/components/KanbanBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/ListView.tsx
Normal file
69
src/components/ListView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
234
src/components/NewTaskModal.tsx
Normal file
234
src/components/NewTaskModal.tsx
Normal 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
221
src/components/Sidebar.tsx
Normal 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
153
src/components/TaskCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/lib/date-utils.test.ts
Normal file
87
src/lib/date-utils.test.ts
Normal 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
124
src/server/db/migrate.ts
Normal 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
8
src/test/setup.ts
Normal 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
17
start.sh
Normal 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
34
tsconfig.json
Normal 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
17
vitest.config.ts
Normal 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/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user