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:
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 { addDays, addWeeks, addMonths, addYears } from 'date-fns';
|
||||
|
||||
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -13,6 +15,7 @@ export async function GET(
|
||||
where: { id },
|
||||
include: {
|
||||
children: { orderBy: { sortOrder: 'asc' } },
|
||||
reminders: { orderBy: { reminder: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +27,10 @@ export async function GET(
|
||||
...task,
|
||||
dueDate: task.dueDate ? task.dueDate.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) => ({
|
||||
...c,
|
||||
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 });
|
||||
}
|
||||
|
||||
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()) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
@@ -68,6 +75,21 @@ export async function PUT(
|
||||
recurrenceRule: recurrenceRule,
|
||||
recurrenceInterval: recurrenceInterval,
|
||||
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,
|
||||
dueDate: task.dueDate ? task.dueDate.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) {
|
||||
console.error('Error updating task:', error);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/db';
|
||||
|
||||
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
@@ -29,13 +31,23 @@ export async function GET(request: NextRequest) {
|
||||
? [{ priority: 'asc' }, { 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(
|
||||
tasks.map((t) => ({
|
||||
...t,
|
||||
dueDate: t.dueDate ? t.dueDate.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) {
|
||||
@@ -52,14 +64,17 @@ export async function POST(request: NextRequest) {
|
||||
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()) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get max sort_order for the project (or overall if no project)
|
||||
const maxSort = await prisma.task.aggregate({
|
||||
where: projectId ? { projectId } : undefined,
|
||||
_max: { sortOrder: true },
|
||||
@@ -77,6 +92,20 @@ export async function POST(request: NextRequest) {
|
||||
recurrenceInterval: recurrenceInterval || 1,
|
||||
parentTaskId: parentTaskId || undefined,
|
||||
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,
|
||||
dueDate: task.dueDate ? task.dueDate.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) {
|
||||
console.error('Error creating task:', error);
|
||||
|
||||
@@ -20,6 +20,13 @@ export interface Task {
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
reminders?: Array<{
|
||||
id: string;
|
||||
taskId: string;
|
||||
reminder: string;
|
||||
sent: boolean;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function EditTaskModal() {
|
||||
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 [reminders, setReminders] = useState<string[]>([]);
|
||||
const [subtasks, setSubtasks] = useState<Task[]>([]);
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||
|
||||
@@ -35,18 +36,35 @@ export default function EditTaskModal() {
|
||||
}
|
||||
}, [editingTask]);
|
||||
|
||||
// Fetch subtasks when modal opens
|
||||
// Fetch subtasks and reminders when modal opens
|
||||
useEffect(() => {
|
||||
if (showEditTaskModal && editingTask) {
|
||||
fetch(`/api/tasks/${editingTask.id}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setSubtasks(data.subtasks || []);
|
||||
if (data.reminders && data.reminders.length > 0) {
|
||||
setReminders(data.reminders.map((r: any) => r.reminder));
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [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 () => {
|
||||
if (!editingTask || !title.trim()) return;
|
||||
|
||||
@@ -63,6 +81,7 @@ export default function EditTaskModal() {
|
||||
status,
|
||||
recurrenceRule,
|
||||
recurrenceInterval,
|
||||
reminders: reminders.filter((r) => r !== ''),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -222,12 +241,50 @@ export default function EditTaskModal() {
|
||||
<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>
|
||||
<label className="block text-xs text-text-secondary mb-1">Due Date</label>
|
||||
<input
|
||||
type="date"
|
||||
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>
|
||||
|
||||
{/* Recurrence */}
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function NewTaskModal() {
|
||||
const [showRecurrence, setShowRecurrence] = useState(false);
|
||||
const [recurrenceRule, setRecurrenceRule] = useState<'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'yearly'>('none');
|
||||
const [recurrenceInterval, setRecurrenceInterval] = useState(1);
|
||||
const [reminders, setReminders] = useState<string[]>([]);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -35,6 +36,20 @@ export default function NewTaskModal() {
|
||||
}
|
||||
}, [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 () => {
|
||||
if (!title.trim()) return;
|
||||
|
||||
@@ -51,6 +66,7 @@ export default function NewTaskModal() {
|
||||
status,
|
||||
recurrenceRule,
|
||||
recurrenceInterval,
|
||||
reminders: reminders.filter((r) => r !== ''),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -59,6 +75,7 @@ export default function NewTaskModal() {
|
||||
setPriority('medium');
|
||||
setDueDate('');
|
||||
setStatus('todo');
|
||||
setReminders([]);
|
||||
setShowRecurrence(false);
|
||||
setRecurrenceRule('none');
|
||||
setRecurrenceInterval(1);
|
||||
@@ -87,11 +104,11 @@ export default function NewTaskModal() {
|
||||
>
|
||||
<div
|
||||
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}
|
||||
>
|
||||
{/* 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>
|
||||
<button
|
||||
onClick={() => setShowNewTaskModal(false)}
|
||||
@@ -147,12 +164,15 @@ export default function NewTaskModal() {
|
||||
<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>
|
||||
<label className="block text-xs text-text-secondary mb-1">Due Date</label>
|
||||
<input
|
||||
type="date"
|
||||
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>
|
||||
|
||||
{/* Status */}
|
||||
@@ -165,6 +185,41 @@ export default function NewTaskModal() {
|
||||
<option value="in_progress">In Progress</option>
|
||||
</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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
@@ -213,7 +268,7 @@ export default function NewTaskModal() {
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
onClick={() => setShowNewTaskModal(false)}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user