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:
Victor
2026-05-09 01:57:49 +00:00
parent d5eb060362
commit 4b8d75617b
11 changed files with 490 additions and 38 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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
View 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);
});

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 */}

View File

@@ -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"