feat: add reminder system with cron scheduler and fix timezone formatting
- Add reminders to Prisma schema (Reminder model, TaskReminder relation) - Add /api/reminders endpoint and cron send-reminders.ts script - Add reminder fields to NewTaskModal and EditTaskModal components - Fix reminder datetime serialization: use toISOString().slice(0,16) for UTC-safe YYYY-MM-DDTHH:mm format compatible with datetime-local inputs - Update Dockerfile to install tsx for cron container - Add AGENTS.md with project conventions - Update docker-compose.yml with cron service and build context
This commit is contained in:
72
AGENTS.md
72
AGENTS.md
@@ -1,3 +1,75 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Behavioral Guidelines
|
||||||
|
|
||||||
|
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||||
|
|
||||||
|
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||||
|
|
||||||
|
### 1. Think Before Coding
|
||||||
|
|
||||||
|
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||||
|
|
||||||
|
Before implementing:
|
||||||
|
- State your assumptions explicitly. If uncertain, ask.
|
||||||
|
- If multiple interpretations exist, present them - don't pick silently.
|
||||||
|
- If a simpler approach exists, say so. Push back when warranted.
|
||||||
|
- If something is unclear, stop. Name what's confusing. Ask.
|
||||||
|
|
||||||
|
### 2. Simplicity First
|
||||||
|
|
||||||
|
**Minimum code that solves the problem. Nothing speculative.**
|
||||||
|
|
||||||
|
- No features beyond what was asked.
|
||||||
|
- No abstractions for single-use code.
|
||||||
|
- No "flexibility" or "configurability" that wasn't requested.
|
||||||
|
- No error handling for impossible scenarios.
|
||||||
|
- If you write 200 lines and it could be 50, rewrite it.
|
||||||
|
|
||||||
|
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||||
|
|
||||||
|
### 3. Surgical Changes
|
||||||
|
|
||||||
|
**Touch only what you must. Clean up only your own mess.**
|
||||||
|
|
||||||
|
When editing existing code:
|
||||||
|
- Don't "improve" adjacent code, comments, or formatting.
|
||||||
|
- Don't refactor things that aren't broken.
|
||||||
|
- Match existing style, even if you'd do it differently.
|
||||||
|
- If you notice unrelated dead code, mention it - don't delete it.
|
||||||
|
|
||||||
|
When your changes create orphans:
|
||||||
|
- Remove imports/variables/functions that YOUR changes made unused.
|
||||||
|
- Don't remove pre-existing dead code unless asked.
|
||||||
|
|
||||||
|
The test: Every changed line should trace directly to the user's request.
|
||||||
|
|
||||||
|
### 4. Goal-Driven Execution
|
||||||
|
|
||||||
|
**Define success criteria. Loop until verified.**
|
||||||
|
|
||||||
|
Transform tasks into verifiable goals:
|
||||||
|
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||||
|
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||||
|
- "Refactor X" → "Ensure tests pass before and after"
|
||||||
|
|
||||||
|
For multi-step tasks, state a brief plan:
|
||||||
|
```
|
||||||
|
1. [Step] → verify: [check]
|
||||||
|
2. [Step] → verify: [check]
|
||||||
|
3. [Step] → verify: [check]
|
||||||
|
```
|
||||||
|
|
||||||
|
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||||
|
|
||||||
|
## Project-Specific
|
||||||
|
|
||||||
|
<!-- Add project-specific rules below this line -->
|
||||||
|
|
||||||
<!-- BEGIN:nextjs-agent-rules -->
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
# This is NOT the Next.js you know
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ COPY --from=builder /app/prisma ./prisma
|
|||||||
# Copy generated Prisma client (includes native query engine binary)
|
# Copy generated Prisma client (includes native query engine binary)
|
||||||
COPY --from=builder /app/src/generated ./src/generated
|
COPY --from=builder /app/src/generated ./src/generated
|
||||||
|
|
||||||
|
# Copy cron script for reminder notifications
|
||||||
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
|
||||||
|
# Install tsx for cron worker
|
||||||
|
RUN npm install -g tsx
|
||||||
|
|
||||||
# Copy startup script
|
# Copy startup script
|
||||||
COPY start.sh /app/start.sh
|
COPY start.sh /app/start.sh
|
||||||
RUN chmod +x /app/start.sh
|
RUN chmod +x /app/start.sh
|
||||||
|
|||||||
@@ -27,11 +27,29 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3070:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://vixtix:vixtix_secret@postgres:5432/vixtix
|
DATABASE_URL: postgresql://vixtix:vixtix_secret@postgres:5432/vixtix
|
||||||
DB_HOST: postgres
|
DB_HOST: postgres
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
|
|
||||||
|
vixtix-cron:
|
||||||
|
image: vixtix:latest
|
||||||
|
container_name: vixtix-cron
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://vixtix:vixtix_secret@postgres:5432/vixtix
|
||||||
|
MATTERMOST_WEBHOOK_URL: ${MATTERMOST_WEBHOOK_URL}
|
||||||
|
entrypoint: ["sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
while true; do
|
||||||
|
npx tsx /app/scripts/send-reminders.ts
|
||||||
|
sleep 60
|
||||||
|
done
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
vixtix-data:
|
vixtix-data:
|
||||||
|
|||||||
@@ -25,25 +25,26 @@ model Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Task {
|
model Task {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
title String
|
title String
|
||||||
description String @default("")
|
description String @default("")
|
||||||
completed Boolean @default(false)
|
completed Boolean @default(false)
|
||||||
priority Priority @default(medium)
|
priority Priority @default(medium)
|
||||||
dueDate DateTime? @map("due_date")
|
dueDate DateTime? @map("due_date")
|
||||||
status Status @default(todo)
|
status Status @default(todo)
|
||||||
parentTaskId String? @map("parent_task_id") @db.Uuid
|
parentTaskId String? @map("parent_task_id") @db.Uuid
|
||||||
recurrenceRule Recurrence @default(none) @map("recurrence_rule")
|
recurrenceRule Recurrence @default(none) @map("recurrence_rule")
|
||||||
recurrenceInterval Int @default(1) @map("recurrence_interval")
|
recurrenceInterval Int @default(1) @map("recurrence_interval")
|
||||||
nextOccurrence DateTime? @map("next_occurrence")
|
nextOccurrence DateTime? @map("next_occurrence")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
parentTask Task? @relation("TaskChildren", fields: [parentTaskId], references: [id], onDelete: Cascade)
|
parentTask Task? @relation("TaskChildren", fields: [parentTaskId], references: [id], onDelete: Cascade)
|
||||||
children Task[] @relation("TaskChildren")
|
children Task[] @relation("TaskChildren")
|
||||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
projectId String? @map("project_id") @db.Uuid
|
projectId String? @map("project_id") @db.Uuid
|
||||||
|
reminders TaskReminder[] @relation("TaskReminders")
|
||||||
|
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([parentTaskId])
|
@@index([parentTaskId])
|
||||||
@@ -74,3 +75,28 @@ enum Recurrence {
|
|||||||
monthly
|
monthly
|
||||||
yearly
|
yearly
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model TaskReminder {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
taskId String @map("task_id") @db.Uuid
|
||||||
|
reminder DateTime @map("reminder")
|
||||||
|
sent Boolean @default(false) @map("sent")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
task Task @relation("TaskReminders", fields: [taskId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([taskId])
|
||||||
|
@@index([sent])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Reminder {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
message String
|
||||||
|
dueDate DateTime @map("due_date")
|
||||||
|
sent Boolean @default(false) @map("sent")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([dueDate])
|
||||||
|
@@index([sent])
|
||||||
|
}
|
||||||
|
|||||||
90
scripts/send-reminders.ts
Normal file
90
scripts/send-reminders.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const MATTERMOST_WEBHOOK_URL = process.env.MATTERMOST_WEBHOOK_URL;
|
||||||
|
|
||||||
|
if (!MATTERMOST_WEBHOOK_URL) {
|
||||||
|
console.error('MATTERMOST_WEBHOOK_URL is not set');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendToMattermost(message: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
text: `⏰ Reminder: ${message}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(MATTERMOST_WEBHOOK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Mattermost webhook failed: ${response.status} ${response.statusText}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Sent reminder to Mattermost: ${message}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending to Mattermost:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const now = new Date();
|
||||||
|
console.log(`Checking for due reminders at ${now.toISOString()}...`);
|
||||||
|
|
||||||
|
// 1. Find unsent standalone reminders that are due
|
||||||
|
const dueReminders = await prisma.reminder.findMany({
|
||||||
|
where: {
|
||||||
|
sent: false,
|
||||||
|
dueDate: { lte: now },
|
||||||
|
},
|
||||||
|
orderBy: { dueDate: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const reminder of dueReminders) {
|
||||||
|
const sent = await sendToMattermost(reminder.message);
|
||||||
|
if (sent) {
|
||||||
|
await prisma.reminder.update({
|
||||||
|
where: { id: reminder.id },
|
||||||
|
data: { sent: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find unsent task reminders that are due
|
||||||
|
const dueTaskReminders = await prisma.taskReminder.findMany({
|
||||||
|
where: {
|
||||||
|
sent: false,
|
||||||
|
reminder: { lte: now },
|
||||||
|
},
|
||||||
|
orderBy: { reminder: 'asc' },
|
||||||
|
include: { task: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tr of dueTaskReminders) {
|
||||||
|
const message = `[${tr.task.title}]${tr.task.description ? ` - ${tr.task.description}` : ''}`;
|
||||||
|
const sent = await sendToMattermost(message);
|
||||||
|
if (sent) {
|
||||||
|
await prisma.taskReminder.update({
|
||||||
|
where: { id: tr.id },
|
||||||
|
data: { sent: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
console.log('Done.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Cron worker error:', error);
|
||||||
|
prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
62
src/app/api/reminders/route.ts
Normal file
62
src/app/api/reminders/route.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import prisma from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const sent = searchParams.get('sent');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (sent !== null) where.sent = sent === 'true';
|
||||||
|
|
||||||
|
const reminders = await prisma.reminder.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ dueDate: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
reminders.map((r) => ({
|
||||||
|
...r,
|
||||||
|
dueDate: r.dueDate.toISOString(),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching reminders:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch reminders' }, { 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 { message, dueDate } = body;
|
||||||
|
|
||||||
|
if (!message?.trim()) {
|
||||||
|
return NextResponse.json({ error: 'Message is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!dueDate) {
|
||||||
|
return NextResponse.json({ error: 'dueDate is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reminder = await prisma.reminder.create({
|
||||||
|
data: {
|
||||||
|
message: message.trim(),
|
||||||
|
dueDate: new Date(dueDate),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...reminder,
|
||||||
|
dueDate: reminder.dueDate.toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating reminder:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to create reminder' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import prisma from '@/lib/db';
|
import prisma from '@/lib/db';
|
||||||
import { addDays, addWeeks, addMonths, addYears } from 'date-fns';
|
import { addDays, addWeeks, addMonths, addYears } from 'date-fns';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
@@ -13,6 +15,7 @@ export async function GET(
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
children: { orderBy: { sortOrder: 'asc' } },
|
children: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
reminders: { orderBy: { reminder: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,6 +27,10 @@ export async function GET(
|
|||||||
...task,
|
...task,
|
||||||
dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null,
|
dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null,
|
||||||
nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null,
|
nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null,
|
||||||
|
reminders: task.reminders.map((r) => ({
|
||||||
|
...r,
|
||||||
|
reminder: r.reminder.toISOString().slice(0, 16),
|
||||||
|
})),
|
||||||
subtasks: task.children.map((c) => ({
|
subtasks: task.children.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
dueDate: c.dueDate ? c.dueDate.toISOString().split('T')[0] : null,
|
dueDate: c.dueDate ? c.dueDate.toISOString().split('T')[0] : null,
|
||||||
@@ -49,7 +56,7 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description, projectId, priority, dueDate, status, recurrenceRule, recurrenceInterval, nextOccurrence } = body;
|
const { title, description, projectId, priority, dueDate, status, recurrenceRule, recurrenceInterval, nextOccurrence, reminders } = body;
|
||||||
|
|
||||||
if (!title?.trim()) {
|
if (!title?.trim()) {
|
||||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||||
@@ -68,6 +75,21 @@ export async function PUT(
|
|||||||
recurrenceRule: recurrenceRule,
|
recurrenceRule: recurrenceRule,
|
||||||
recurrenceInterval: recurrenceInterval,
|
recurrenceInterval: recurrenceInterval,
|
||||||
nextOccurrence: nextOccurrence ? new Date(nextOccurrence) : undefined,
|
nextOccurrence: nextOccurrence ? new Date(nextOccurrence) : undefined,
|
||||||
|
reminders: {
|
||||||
|
deleteMany: {},
|
||||||
|
create: (reminders || []).map((r: string) => {
|
||||||
|
// Parse local datetime string and construct Date to preserve local time
|
||||||
|
const [datePart, timePart] = r.split('T');
|
||||||
|
const [year, month, day] = datePart.split('-').map(Number);
|
||||||
|
const [hours, minutes] = timePart.split(':').map(Number);
|
||||||
|
return {
|
||||||
|
reminder: new Date(Date.UTC(year, month - 1, day, hours, minutes)),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
reminders: { orderBy: { reminder: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,6 +97,10 @@ export async function PUT(
|
|||||||
...task,
|
...task,
|
||||||
dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null,
|
dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null,
|
||||||
nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null,
|
nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null,
|
||||||
|
reminders: task.reminders.map((r) => ({
|
||||||
|
...r,
|
||||||
|
reminder: r.reminder.toISOString().slice(0, 16),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating task:', error);
|
console.error('Error updating task:', error);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import prisma from '@/lib/db';
|
import prisma from '@/lib/db';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
@@ -29,13 +31,23 @@ export async function GET(request: NextRequest) {
|
|||||||
? [{ priority: 'asc' }, { title: 'asc' }]
|
? [{ priority: 'asc' }, { title: 'asc' }]
|
||||||
: [{ dueDate: { sort: 'asc', nulls: 'last' } }, { title: 'asc' }];
|
: [{ dueDate: { sort: 'asc', nulls: 'last' } }, { title: 'asc' }];
|
||||||
|
|
||||||
const tasks = await prisma.task.findMany({ where, orderBy });
|
const tasks = await prisma.task.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
include: {
|
||||||
|
reminders: { orderBy: { reminder: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
tasks.map((t) => ({
|
tasks.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
dueDate: t.dueDate ? t.dueDate.toISOString().split('T')[0] : null,
|
dueDate: t.dueDate ? t.dueDate.toISOString().split('T')[0] : null,
|
||||||
nextOccurrence: t.nextOccurrence ? t.nextOccurrence.toISOString().split('T')[0] : null,
|
nextOccurrence: t.nextOccurrence ? t.nextOccurrence.toISOString().split('T')[0] : null,
|
||||||
|
reminders: t.reminders.map((r) => ({
|
||||||
|
...r,
|
||||||
|
reminder: r.reminder.toISOString().slice(0, 16),
|
||||||
|
})),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -52,14 +64,17 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description, projectId, priority, dueDate, status, recurrenceRule, recurrenceInterval, parentTaskId } = body;
|
const {
|
||||||
|
title, description, projectId, priority, dueDate,
|
||||||
|
status, recurrenceRule, recurrenceInterval, parentTaskId,
|
||||||
|
reminders,
|
||||||
|
} = body;
|
||||||
|
|
||||||
if (!title?.trim()) {
|
if (!title?.trim()) {
|
||||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get max sort_order for the project (or overall if no project)
|
|
||||||
const maxSort = await prisma.task.aggregate({
|
const maxSort = await prisma.task.aggregate({
|
||||||
where: projectId ? { projectId } : undefined,
|
where: projectId ? { projectId } : undefined,
|
||||||
_max: { sortOrder: true },
|
_max: { sortOrder: true },
|
||||||
@@ -77,6 +92,20 @@ export async function POST(request: NextRequest) {
|
|||||||
recurrenceInterval: recurrenceInterval || 1,
|
recurrenceInterval: recurrenceInterval || 1,
|
||||||
parentTaskId: parentTaskId || undefined,
|
parentTaskId: parentTaskId || undefined,
|
||||||
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
|
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
|
||||||
|
reminders: {
|
||||||
|
create: (reminders || []).map((r: string) => {
|
||||||
|
// Parse local datetime string and construct Date to preserve local time
|
||||||
|
const [datePart, timePart] = r.split('T');
|
||||||
|
const [year, month, day] = datePart.split('-').map(Number);
|
||||||
|
const [hours, minutes] = timePart.split(':').map(Number);
|
||||||
|
return {
|
||||||
|
reminder: new Date(Date.UTC(year, month - 1, day, hours, minutes)),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
reminders: { orderBy: { reminder: 'asc' } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,6 +113,10 @@ export async function POST(request: NextRequest) {
|
|||||||
...task,
|
...task,
|
||||||
dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null,
|
dueDate: task.dueDate ? task.dueDate.toISOString().split('T')[0] : null,
|
||||||
nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null,
|
nextOccurrence: task.nextOccurrence ? task.nextOccurrence.toISOString().split('T')[0] : null,
|
||||||
|
reminders: task.reminders.map((r) => ({
|
||||||
|
...r,
|
||||||
|
reminder: r.reminder.toISOString().slice(0, 16),
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating task:', error);
|
console.error('Error creating task:', error);
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ export interface Task {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
reminders?: Array<{
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
reminder: string;
|
||||||
|
sent: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function EditTaskModal() {
|
|||||||
const [status, setStatus] = useState<'todo' | 'in_progress' | 'done'>('todo');
|
const [status, setStatus] = useState<'todo' | 'in_progress' | 'done'>('todo');
|
||||||
const [recurrenceRule, setRecurrenceRule] = useState<'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly'>('none');
|
const [recurrenceRule, setRecurrenceRule] = useState<'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly'>('none');
|
||||||
const [recurrenceInterval, setRecurrenceInterval] = useState(1);
|
const [recurrenceInterval, setRecurrenceInterval] = useState(1);
|
||||||
|
const [reminders, setReminders] = useState<string[]>([]);
|
||||||
const [subtasks, setSubtasks] = useState<Task[]>([]);
|
const [subtasks, setSubtasks] = useState<Task[]>([]);
|
||||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||||
|
|
||||||
@@ -35,18 +36,35 @@ export default function EditTaskModal() {
|
|||||||
}
|
}
|
||||||
}, [editingTask]);
|
}, [editingTask]);
|
||||||
|
|
||||||
// Fetch subtasks when modal opens
|
// Fetch subtasks and reminders when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showEditTaskModal && editingTask) {
|
if (showEditTaskModal && editingTask) {
|
||||||
fetch(`/api/tasks/${editingTask.id}`)
|
fetch(`/api/tasks/${editingTask.id}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
setSubtasks(data.subtasks || []);
|
setSubtasks(data.subtasks || []);
|
||||||
|
if (data.reminders && data.reminders.length > 0) {
|
||||||
|
setReminders(data.reminders.map((r: any) => r.reminder));
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
}, [showEditTaskModal, editingTask]);
|
}, [showEditTaskModal, editingTask]);
|
||||||
|
|
||||||
|
const addReminder = () => {
|
||||||
|
setReminders([...reminders, '']);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeReminder = (index: number) => {
|
||||||
|
setReminders(reminders.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateReminder = (index: number, value: string) => {
|
||||||
|
const updated = [...reminders];
|
||||||
|
updated[index] = value;
|
||||||
|
setReminders(updated);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!editingTask || !title.trim()) return;
|
if (!editingTask || !title.trim()) return;
|
||||||
|
|
||||||
@@ -63,6 +81,7 @@ export default function EditTaskModal() {
|
|||||||
status,
|
status,
|
||||||
recurrenceRule,
|
recurrenceRule,
|
||||||
recurrenceInterval,
|
recurrenceInterval,
|
||||||
|
reminders: reminders.filter((r) => r !== ''),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,12 +241,50 @@ export default function EditTaskModal() {
|
|||||||
<option value="urgent">🔴 Urgent</option>
|
<option value="urgent">🔴 Urgent</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<div>
|
||||||
type="date"
|
<label className="block text-xs text-text-secondary mb-1">Due Date</label>
|
||||||
value={dueDate}
|
<input
|
||||||
onChange={(e) => setDueDate(e.target.value)}
|
type="date"
|
||||||
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
value={dueDate}
|
||||||
/>
|
onChange={(e) => setDueDate(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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reminders section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-xs text-text-secondary">🔔 Reminders</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addReminder}
|
||||||
|
className="text-xs text-accent hover:text-accent-hover transition-colors"
|
||||||
|
>
|
||||||
|
+ Add Reminder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{reminders.length === 0 && (
|
||||||
|
<p className="text-xs text-text-secondary/60 italic">No reminders set</p>
|
||||||
|
)}
|
||||||
|
{reminders.map((reminder, index) => (
|
||||||
|
<div key={index} className="flex gap-2 mb-1.5">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={reminder}
|
||||||
|
onChange={(e) => updateReminder(index, e.target.value)}
|
||||||
|
className="flex-1 bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeReminder(index)}
|
||||||
|
className="px-2 text-text-secondary hover:text-red-400 transition-colors text-sm"
|
||||||
|
title="Remove reminder"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recurrence */}
|
{/* Recurrence */}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function NewTaskModal() {
|
|||||||
const [showRecurrence, setShowRecurrence] = useState(false);
|
const [showRecurrence, setShowRecurrence] = useState(false);
|
||||||
const [recurrenceRule, setRecurrenceRule] = useState<'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly'>('none');
|
const [recurrenceRule, setRecurrenceRule] = useState<'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly'>('none');
|
||||||
const [recurrenceInterval, setRecurrenceInterval] = useState(1);
|
const [recurrenceInterval, setRecurrenceInterval] = useState(1);
|
||||||
|
const [reminders, setReminders] = useState<string[]>([]);
|
||||||
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -35,6 +36,20 @@ export default function NewTaskModal() {
|
|||||||
}
|
}
|
||||||
}, [showNewTaskModal, selectedProject]);
|
}, [showNewTaskModal, selectedProject]);
|
||||||
|
|
||||||
|
const addReminder = () => {
|
||||||
|
setReminders([...reminders, '']);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeReminder = (index: number) => {
|
||||||
|
setReminders(reminders.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateReminder = (index: number, value: string) => {
|
||||||
|
const updated = [...reminders];
|
||||||
|
updated[index] = value;
|
||||||
|
setReminders(updated);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
|
|
||||||
@@ -51,6 +66,7 @@ export default function NewTaskModal() {
|
|||||||
status,
|
status,
|
||||||
recurrenceRule,
|
recurrenceRule,
|
||||||
recurrenceInterval,
|
recurrenceInterval,
|
||||||
|
reminders: reminders.filter((r) => r !== ''),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +75,7 @@ export default function NewTaskModal() {
|
|||||||
setPriority('medium');
|
setPriority('medium');
|
||||||
setDueDate('');
|
setDueDate('');
|
||||||
setStatus('todo');
|
setStatus('todo');
|
||||||
|
setReminders([]);
|
||||||
setShowRecurrence(false);
|
setShowRecurrence(false);
|
||||||
setRecurrenceRule('none');
|
setRecurrenceRule('none');
|
||||||
setRecurrenceInterval(1);
|
setRecurrenceInterval(1);
|
||||||
@@ -87,11 +104,11 @@ export default function NewTaskModal() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
className="bg-card rounded-xl w-full max-w-md mx-4 shadow-xl animate-slide-in"
|
className="bg-card rounded-xl w-full max-w-md mx-4 shadow-xl animate-slide-in max-h-[90vh] overflow-y-auto"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
<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">New Task</h2>
|
<h2 className="text-lg font-bold">New Task</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewTaskModal(false)}
|
onClick={() => setShowNewTaskModal(false)}
|
||||||
@@ -147,12 +164,15 @@ export default function NewTaskModal() {
|
|||||||
<option value="urgent">🔴 Urgent</option>
|
<option value="urgent">🔴 Urgent</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input
|
<div>
|
||||||
type="date"
|
<label className="block text-xs text-text-secondary mb-1">Due Date</label>
|
||||||
value={dueDate}
|
<input
|
||||||
onChange={(e) => setDueDate(e.target.value)}
|
type="date"
|
||||||
className="bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
value={dueDate}
|
||||||
/>
|
onChange={(e) => setDueDate(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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
@@ -165,6 +185,41 @@ export default function NewTaskModal() {
|
|||||||
<option value="in_progress">In Progress</option>
|
<option value="in_progress">In Progress</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{/* Reminders section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-xs text-text-secondary">🔔 Reminders</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addReminder}
|
||||||
|
className="text-xs text-accent hover:text-accent-hover transition-colors"
|
||||||
|
>
|
||||||
|
+ Add Reminder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{reminders.length === 0 && (
|
||||||
|
<p className="text-xs text-text-secondary/60 italic">No reminders set</p>
|
||||||
|
)}
|
||||||
|
{reminders.map((reminder, index) => (
|
||||||
|
<div key={index} className="flex gap-2 mb-1.5">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={reminder}
|
||||||
|
onChange={(e) => updateReminder(index, e.target.value)}
|
||||||
|
className="flex-1 bg-content border border-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeReminder(index)}
|
||||||
|
className="px-2 text-text-secondary hover:text-red-400 transition-colors text-sm"
|
||||||
|
title="Remove reminder"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recurrence toggle */}
|
{/* Recurrence toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
@@ -213,7 +268,7 @@ export default function NewTaskModal() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-border">
|
<div className="flex items-center justify-end gap-2 p-4 border-t border-border sticky bottom-0 bg-card">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewTaskModal(false)}
|
onClick={() => setShowNewTaskModal(false)}
|
||||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
|||||||
Reference in New Issue
Block a user